samagotchi 0.130.0 → 0.131.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +15 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/desktop.md +28 -9
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/participants.md +36 -9
  9. data/docs/internals/prompt-caching.md +5 -2
  10. data/docs/sessions.md +4 -2
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/cache_confirm.rb +29 -9
  13. data/lib/samagotchi/desktop/macos.rb +99 -19
  14. data/lib/samagotchi/desktop_command.rb +7 -2
  15. data/lib/samagotchi/engine.rb +31 -2
  16. data/lib/samagotchi/guardrails/rules.rb +2 -2
  17. data/lib/samagotchi/guardrails/targets.rb +13 -0
  18. data/lib/samagotchi/level_setting_command.rb +3 -2
  19. data/lib/samagotchi/session.rb +49 -4
  20. data/lib/samagotchi/session_commands.rb +4 -4
  21. data/lib/samagotchi/toolkit_command.rb +2 -2
  22. data/lib/samagotchi/tools/execute.rb +5 -2
  23. data/lib/samagotchi/tools/task_runtime.rb +4 -2
  24. data/lib/samagotchi/update_command.rb +9 -1
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +47 -8
  27. data/lib/samagotchi/web/participant_review.rb +9 -2
  28. data/lib/samagotchi/web/participant_view.rb +22 -6
  29. data/lib/samagotchi/web/proxy_gate.rb +6 -0
  30. data/lib/samagotchi/web/public/annotate_bar.js +6 -1
  31. data/lib/samagotchi/web/public/app.js +14 -5
  32. data/lib/samagotchi/web/public/participant.js +24 -6
  33. data/lib/samagotchi/web/public/verdict_card.js +48 -4
  34. data/lib/samagotchi/web/public/verdict_fresh.js +8 -4
  35. data/lib/samagotchi/web/public/verify_target.js +73 -0
  36. data/lib/samagotchi/web/token.rb +20 -1
  37. data/lib/samagotchi/worker.rb +7 -0
  38. metadata +2 -1
@@ -41,9 +41,11 @@ module Samagotchi
41
41
  BUILD_NAME = ".chi-helper-build"
42
42
  OLD_NAME = ".chi-helper-old"
43
43
  # Apps started by launchd get a bare env (no shell rc files), so the
44
- # launch file carries what chi needs to find its gems and state. Only
45
- # these, and only when set: no tokens, no runtime knobs.
44
+ # launch file carries what chi needs to find its gems, state and tools.
45
+ # Only these, and only when set: no tokens, no runtime knobs.
46
46
  ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
47
+ # PATH is separately filtered to absolute dirs and frozen when written,
48
+ # so the helper's chi, workers and execute can find the shell's tools.
47
49
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
48
50
  LANG = "en_US.UTF-8"
49
51
  # chi broadcast's default triage deadline, and the helper's seconds on
@@ -54,6 +56,17 @@ module Samagotchi
54
56
  # one launch-file section each, in this order.
55
57
  TERMINALS = %w[kitty tmux].freeze
56
58
 
59
+ # PATH dirs the helper can use from launchd's / working directory:
60
+ # keep absolute entries only, in order, once each.
61
+ def self.path_dirs(env)
62
+ env["PATH"].to_s.split(File::PATH_SEPARATOR).select { _1.start_with?("/") }.uniq
63
+ end
64
+
65
+ def self.baked_path(env)
66
+ dirs = path_dirs(env)
67
+ dirs.empty? ? nil : dirs.join(File::PATH_SEPARATOR)
68
+ end
69
+
57
70
  # Runs a command without a shell; [output, success].
58
71
  class Runner
59
72
  def run(argv)
@@ -76,7 +89,8 @@ module Samagotchi
76
89
 
77
90
  # The tmux: settings, or nil while tmux.enabled is off. The binary is
78
91
  # tmux.binary, else the tmux on +env+'s PATH ("" when there is none:
79
- # see #warnings); the socket dir is where tmux puts `-L` sockets
92
+ # only absolute dirs are usable from launchd; see #warnings); the socket
93
+ # dir is where tmux puts `-L` sockets
80
94
  # ($TMUX_TMPDIR when it is a directory, else /tmp, then tmux-<uid>).
81
95
  # Both are frozen at bake time, like the rest of the launch file.
82
96
  def self.tmux_settings(get = Config.method(:get), env: ENV)
@@ -91,8 +105,7 @@ module Samagotchi
91
105
  end
92
106
 
93
107
  def self.path_tmux(env)
94
- env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(_1, "tmux") }
95
- .find { File.file?(_1) && File.executable?(_1) }.to_s
108
+ path_dirs(env).map { File.join(_1, "tmux") }.find { File.file?(_1) && File.executable?(_1) }.to_s
96
109
  end
97
110
 
98
111
  # Each switched-on terminal's settings by name ("kitty" => …).
@@ -148,10 +161,14 @@ module Samagotchi
148
161
  # `gem cleanup`), else this checkout's bin/chi. A section per terminal
149
162
  # switched on (TERMINALS order); the broadcast timeout when given.
150
163
  # The XDG dirs go in absolute (Paths): launchd starts the app in /,
151
- # where a relative one would mean /<dir>.
164
+ # where a relative one would mean /<dir>. PATH is filtered to absolute,
165
+ # deduplicated dirs so chi, its workers and execute find shell tools;
166
+ # it is frozen at bake time.
152
167
  def launch_config
153
168
  env = { "LANG" => LANG }
154
169
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
170
+ path = self.class.baked_path(@env)
171
+ env["PATH"] = path if path
155
172
  env.merge!(Paths.absolute_xdg(env: env, set: :key))
156
173
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
157
174
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
@@ -178,22 +195,55 @@ module Samagotchi
178
195
  launch["sources_sha"] != sources_sha || !launch_ok?(launch)
179
196
  end
180
197
 
181
- # The launch file names another chi version or path than this one, or
182
- # other terminal settings or broadcast timeout: refresh_launch_file
183
- # fixes that without a rebuild.
198
+ # The launch file names an older chi version than this one (or none), or
199
+ # other terminal settings, broadcast timeout or a missing bakeable PATH:
200
+ # refresh_launch_file fixes that without a rebuild. A different argv or
201
+ # baked PATH, or a newer version, is intentional: the ruby, chi and PATH
202
+ # the helper runs change only with chi desktop install --force or upgrade.
184
203
  def launch_outdated?
185
204
  launch = read_launch
186
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] ||
205
+ older_version?(launch["version"]) ||
187
206
  TERMINALS.map { launch[_1] } != TERMINALS.map { @terminals[_1] } ||
188
- launch["broadcast_timeout"] != @broadcast_timeout
207
+ launch["broadcast_timeout"] != @broadcast_timeout || path_missing?(launch)
208
+ end
209
+
210
+ # Why chi update can't keep the launch file's ruby or chi: one of them
211
+ # is gone. nil when it runs, or has no usable argv (a refresh writes
212
+ # this chi's then).
213
+ def launch_problem
214
+ argv = usable_argv(read_launch)
215
+ missing = Array(argv).reject { |path| File.exist?(path) }
216
+ return nil if missing.empty?
217
+
218
+ "chi not found at #{missing.join(", ")}: run chi desktop install --force from the shell whose chi it should run"
219
+ end
220
+
221
+ # The chi version the launch file names, nil without one.
222
+ def launch_version = read_launch["version"]
223
+
224
+ # The launch file names a newer chi than this one: an older chi's
225
+ # chi update keeps the helper as it is (no rebuild from older sources).
226
+ def launch_newer?
227
+ Gem::Version.new(launch_version.to_s) > Gem::Version.new(@version)
228
+ rescue ArgumentError
229
+ false
189
230
  end
190
231
 
191
232
  # Rewrite the launch file for this chi and its settings, keeping
192
- # the env it was installed with. The app reads it at each send: no restart.
233
+ # its argv and the env it was installed with; a PATH only when it has
234
+ # none (changing them takes chi desktop install --force or upgrade). A
235
+ # newer version stays. The app reads it at each send.
193
236
  def refresh_launch_file
194
237
  config = launch_config
195
- env = read_launch["env"]
196
- config["env"] = env if env.is_a?(Hash)
238
+ old = read_launch
239
+ config["argv"] = usable_argv(old) || config["argv"]
240
+ config["version"] = old["version"] unless older_version?(old["version"])
241
+ old_env = old["env"]
242
+ if old_env.is_a?(Hash)
243
+ config["env"] = old_env
244
+ path = self.class.baked_path(@env)
245
+ config["env"]["PATH"] = path if config["env"]["PATH"].to_s.empty? && path
246
+ end
197
247
  write_launch_file(config)
198
248
  end
199
249
 
@@ -242,7 +292,9 @@ module Samagotchi
242
292
 
243
293
  # @yield [String] progress lines
244
294
  # @param login [Boolean] also start the app at login
245
- def install(force: false, login: false, &progress)
295
+ # @param keep_launch [Boolean] keep the launch file's ruby, chi and PATH
296
+ # (chi update's rebuild) instead of baking this shell's
297
+ def install(force: false, login: false, keep_launch: false, &progress)
246
298
  raise Error, "already installed (#{app_version}); use chi desktop upgrade" if installed? && !force
247
299
 
248
300
  toolchain!
@@ -250,7 +302,7 @@ module Samagotchi
250
302
  progress&.call("building #{APP_NAME} #{@version} (can take a few minutes)…")
251
303
  built = build_app
252
304
  swap_in(built)
253
- write_launch_file
305
+ write_launch_file(keep_launch: keep_launch)
254
306
  register_app
255
307
  self.login = true if login
256
308
  progress&.call("installed #{app_path}")
@@ -258,9 +310,9 @@ module Samagotchi
258
310
  end
259
311
 
260
312
  # Keeps the login item as it was.
261
- def upgrade(&)
313
+ def upgrade(keep_launch: false, &)
262
314
  was_login = installed? && login == "enabled"
263
- install(force: true, login: was_login, &)
315
+ install(force: true, login: was_login, keep_launch: keep_launch, &)
264
316
  end
265
317
 
266
318
  # SMAppService status as the app reports it ("enabled",
@@ -313,6 +365,7 @@ module Samagotchi
313
365
  launch_ok: launch_ok?(launch),
314
366
  stale: stale?,
315
367
  baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
368
+ baked_path: env["PATH"],
316
369
  service: dump.include?(BUNDLE_ID),
317
370
  running: running,
318
371
  host_keys: host_keys
@@ -393,12 +446,39 @@ module Samagotchi
393
446
  {}
394
447
  end
395
448
 
449
+ # This chi is newer than the version the launch file names; a missing or
450
+ # unreadable version counts as older.
451
+ def older_version?(version)
452
+ Gem::Version.new(@version) > Gem::Version.new(version.to_s)
453
+ rescue ArgumentError
454
+ true
455
+ end
456
+
457
+ def usable_argv(launch)
458
+ argv = launch["argv"]
459
+ argv if argv.is_a?(Array) && !argv.empty? && argv.all? { |path| path.is_a?(String) && !path.empty? }
460
+ end
461
+
462
+ def path_missing?(launch)
463
+ return false unless self.class.baked_path(@env)
464
+
465
+ env = launch["env"]
466
+ !env.is_a?(Hash) || env["PATH"].to_s.empty?
467
+ end
468
+
396
469
  def launch_ok?(launch)
397
470
  argv = Array(launch["argv"])
398
471
  !argv.empty? && argv.all? { |path| File.exist?(path) }
399
472
  end
400
473
 
401
- def write_launch_file(config = launch_config)
474
+ def write_launch_file(config = launch_config, keep_launch: false)
475
+ if keep_launch
476
+ old = read_launch
477
+ config["argv"] = usable_argv(old) || config["argv"]
478
+ old_env = old["env"]
479
+ old_path = old_env["PATH"] if old_env.is_a?(Hash)
480
+ config["env"]["PATH"] = old_path unless old_path.to_s.empty?
481
+ end
402
482
  FileUtils.mkdir_p(@support_dir)
403
483
  AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
404
484
  end
@@ -118,11 +118,12 @@ module Samagotchi
118
118
  "#{s[:app_version] || "?"} (up to date: chi #{s[:chi_version]} changed nothing in it)"
119
119
  end
120
120
  launch = s[:launch_argv].empty? ? "(none)" : s[:launch_argv].join(" ")
121
- launch += s[:launch_ok] ? " (ok)" : " (missing: chi update)"
121
+ launch += s[:launch_ok] ? " (ok)" : " (missing: chi desktop install --force)"
122
122
  dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
123
+ path = path_row(s[:baked_path])
123
124
  service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
124
125
  running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
125
- rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs],
126
+ rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs], ["PATH", path],
126
127
  ["service", service], ["hotkey", hotkey(s[:hotkey])], ["process", running], ["at login", login(s[:login])]]
127
128
  rows << ["host keys", host_keys(s[:host_keys])] unless Array(s[:host_keys]).empty?
128
129
  @stdout.puts(format_rows(rows))
@@ -153,6 +154,10 @@ module Samagotchi
153
154
  end
154
155
  end
155
156
 
157
+ def path_row(path)
158
+ path.to_s.empty? ? "(none: macOS's default; chi update fills it, chi desktop install --force changes it)" : path
159
+ end
160
+
156
161
  def format_rows(rows)
157
162
  width = rows.map { |label, _| label.length }.max
158
163
  rows.map { |label, value| "#{label.ljust(width)} #{value}" }.join("\n")
@@ -16,6 +16,7 @@ require_relative "turn_note"
16
16
  require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
18
  require_relative "verify_anchor"
19
+ require_relative "web/participant_view"
19
20
  require_relative "verdict"
20
21
  require_relative "model_profile"
21
22
  require_relative "effective_model"
@@ -1352,6 +1353,19 @@ module Samagotchi
1352
1353
  Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
1353
1354
  end
1354
1355
 
1356
+ # The latest known context size used by a request, for a cache-restart
1357
+ # confirmation; nil before any request reports a count.
1358
+ # @return [Integer, nil]
1359
+ def cache_restart_tokens
1360
+ used = @metrics.snapshot.dig(:context, :used_tokens)
1361
+ used if used.is_a?(Integer)
1362
+ end
1363
+
1364
+ # Whether a cache restart can invalidate preserved thinking blocks.
1365
+ def cache_restart_anthropic?
1366
+ effective_model.entry&.api.to_s == "anthropic"
1367
+ end
1368
+
1355
1369
  # What a thinking level change costs the effective model's prompt
1356
1370
  # cache (Thinking.cache_cost): :full, :provider or :tail.
1357
1371
  # @return [Symbol]
@@ -1646,13 +1660,28 @@ module Samagotchi
1646
1660
  return reject_verify_anchor(anchor, :reply_missing) unless reply
1647
1661
 
1648
1662
  quote_text = verify_comparison_text(anchor.quote)
1649
- answer_text = verify_comparison_text(reply[:content] || reply["content"])
1650
- return reject_verify_anchor(anchor, :quote_missing) if quote_text.empty? || !answer_text.include?(quote_text)
1663
+ quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
1664
+ return reject_verify_anchor(anchor, :quote_missing) unless quoted
1651
1665
 
1652
1666
  anchor
1653
1667
  end
1654
1668
  private :verified_anchor
1655
1669
 
1670
+ # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
1671
+ # pages show it: a page quotes its own text, which reads the session's
1672
+ # folder relative, the home folder as ~ and a secret shape as
1673
+ # [redacted] (a participant's page gets it so, ParticipantView::Redact;
1674
+ # the owner's page rewrites its quote the same way, claim_link.js
1675
+ # normalizeClaim, so claim links match both pages). +source+ is the
1676
+ # session whose folder that is.
1677
+ def verify_quote_in?(quote_text, answer, source)
1678
+ return true if verify_comparison_text(answer).include?(quote_text)
1679
+
1680
+ viewer = Web::ParticipantView::Viewer.new(cwd: source.working_directory)
1681
+ verify_comparison_text(Web::ParticipantView::Redact.text(answer, viewer)).include?(quote_text)
1682
+ end
1683
+ private :verify_quote_in?
1684
+
1656
1685
  def verify_anchor_source_session(session, anchor)
1657
1686
  return session unless session.verify_fresh
1658
1687
 
@@ -112,8 +112,8 @@ module Samagotchi
112
112
  # A resolved match only adds an ask or a deny; were a verdict
113
113
  # ever to let a call through, a symlink into its glob must not.
114
114
  if resolved_match?
115
- real_base ||= PathResolve.real(base)
116
- forms += PathResolve.reals(p).map { |real| [real, real_base] }
115
+ real_base ||= targets.real_rule_base
116
+ forms += targets.real_forms(p).map { |real| [real, real_base] }
117
117
  end
118
118
  forms.any? do |form, from|
119
119
  candidate = absolute ? form : relative(form, from)
@@ -379,6 +379,19 @@ module Samagotchi
379
379
  @paths.any? { |p| Outside.outside?(p, root: @session_root) }
380
380
  end
381
381
 
382
+ # The resolved forms used by rules' path: globs, once per target path.
383
+ def real_forms(path)
384
+ @real_forms ||= {}
385
+ @real_forms[path] ||= PathResolve.reals(path)
386
+ end
387
+
388
+ # The resolved base used by rules' path: globs.
389
+ def real_rule_base
390
+ return @real_rule_base if defined?(@real_rule_base)
391
+
392
+ @real_rule_base = PathResolve.real(@repo_root || @cwd)
393
+ end
394
+
382
395
  # Where a shell call runs mutating git (ShellGitDirs: dirs, and
383
396
  # :unknown for one the text doesn't tell); [] for other tools.
384
397
  def git_dirs
@@ -81,9 +81,10 @@ module Samagotchi
81
81
  return nil unless before && would
82
82
 
83
83
  cost = spec.cache_cost.call(@engine, before.level, would.level)
84
- return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
84
+ step = CacheConfirm.ask(line: "#{spec.name} #{word}", cost: cost, changed: would.level != before.level,
85
+ engine: @engine)
86
+ return nil unless step
85
87
 
86
- step = CacheConfirm.confirm(spec.name, word)
87
88
  [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{spec.cost_notes[cost]}", step.text].join("\n"),
88
89
  false, step]
89
90
  end
@@ -14,6 +14,7 @@ require_relative "session_setup"
14
14
  require_relative "prompt_note"
15
15
  require_relative "working_set_pin"
16
16
  require_relative "session_review"
17
+ require_relative "version"
17
18
 
18
19
  require_relative "paths"
19
20
  require_relative "project_scope"
@@ -351,17 +352,27 @@ module Samagotchi
351
352
  # saved.
352
353
  attr_accessor :opener
353
354
 
355
+ # The top-level keys of the session file this chi doesn't know (a newer
356
+ # chi's fields), frozen, string keys: #to_h writes them back after the
357
+ # FIELDS as they were. Never read by this chi.
358
+ attr_reader :extra_fields
359
+
360
+ # The chi version that last saved the loaded file (#to_h writes this
361
+ # chi's VERSION); nil for a file from 0.130.0 or older.
362
+ attr_reader :written_by
363
+
354
364
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
355
- metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
365
+ metadata_version: METADATA_VERSION, written_by: nil, status: STATUS_IDLE, last_prompt: "",
356
366
  first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
357
367
  used_memory_names: [], project_root: nil,
358
368
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
359
369
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
360
370
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
361
371
  toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
362
- working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil)
372
+ working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil, extra_fields: {})
363
373
  @id = id
364
374
  @metadata_version = metadata_version
375
+ @written_by = written_by
365
376
  @mode = mode
366
377
  @model_name = model_name
367
378
  @model_typed = model_typed
@@ -399,12 +410,25 @@ module Samagotchi
399
410
  self.verdicts = verdicts
400
411
  self.verify_fresh = verify_fresh
401
412
  self.interrupted_turn = interrupted_turn
413
+ @extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
402
414
  @turn_checkpoint = nil
403
415
  @archived = false
404
416
  # #save's lock: one snapshot-and-write at a time (see #save).
405
417
  @save_lock = Mutex.new
406
418
  end
407
419
 
420
+ # Whether a chi newer than this one saved the loaded file: its
421
+ # written_by is a later version, or it has keys this chi doesn't know
422
+ # (a hand-edited or future file). The worker logs it (newer_writer).
423
+ def newer_writer?
424
+ return true unless @extra_fields.empty?
425
+
426
+ # correct?(nil) is true (a 0 version): a file without the field is no newer.
427
+ return false unless @written_by.is_a?(String) && Gem::Version.correct?(@written_by)
428
+
429
+ Gem::Version.new(@written_by) > Gem::Version.new(VERSION)
430
+ end
431
+
408
432
  # A `delegate` child: its turns report to its parent (ChildReports). A
409
433
  # child saved before the field is known by the memory every delegate
410
434
  # starts with; a fork is neither.
@@ -543,8 +567,24 @@ module Samagotchi
543
567
  # the way (symbols in memory, strings on disk); last_turn, prompt_notes
544
568
  # and pending_question are value objects in memory (LastTurn,
545
569
  # PromptNote, PendingQuestion).
570
+ #
571
+ # A new field: a chi older than it (from the release after 0.130.0)
572
+ # keeps the key as it was (#extra_fields) but doesn't honour it, and the
573
+ # next newer chi reads it again. So it must be safe to come back
574
+ # unchanged after an older chi ran turns on the session: static data, or
575
+ # append-only data the older chi never appends to (review, verify_fresh,
576
+ # first_turn_id, verdicts); a cursor into messages, which an older chi
577
+ # would have moved, needs a check against what it points at. Only
578
+ # top-level keys are kept: the value objects (SessionReview,
579
+ # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
580
+ # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
581
+ # only, so an older chi still drops a new sub-key of one of them; add a
582
+ # top-level field when an older chi must keep it. A removed key would
583
+ # stay an extra forever: leave it out of the extras in .from_h then (no
584
+ # key was ever removed).
546
585
  FIELDS = {
547
586
  "metadata_version" => 1,
587
+ "written_by" => nil,
548
588
  "id" => REQUIRED,
549
589
  "mode" => REQUIRED,
550
590
  "model_name" => REQUIRED,
@@ -599,6 +639,7 @@ module Samagotchi
599
639
  end
600
640
  attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
601
641
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
642
+ attrs[:extra_fields] = data.reject { |key, _| FIELDS.key?(key) }
602
643
  new(**attrs)
603
644
  end
604
645
 
@@ -720,8 +761,10 @@ module Samagotchi
720
761
  self
721
762
  end
722
763
 
723
- # The session as #save writes it (FIELDS, in their order); a running
724
- # turn's checkpoint (#turn_checkpoint) in place of its messages.
764
+ # The session as #save writes it: FIELDS, in their order, then the keys
765
+ # this chi doesn't know (#extra_fields, in the file's order; a FIELDS key
766
+ # wins); a running turn's checkpoint (#turn_checkpoint) in place of its
767
+ # messages.
725
768
  def to_h
726
769
  checkpoint = checkpointed
727
770
  FIELDS.keys.to_h do |key|
@@ -730,6 +773,7 @@ module Samagotchi
730
773
  (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
731
774
  when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
732
775
  when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
776
+ when "written_by" then VERSION
733
777
  when "test_run" then !!@test_run
734
778
  when "delegate" then @delegate
735
779
  when "llm_context" then llm_context_file
@@ -755,6 +799,7 @@ module Samagotchi
755
799
  end
756
800
  [key, value]
757
801
  end.reject { |key, value| value.nil? && OPTIONAL_FIELDS.include?(key) }
802
+ .merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
758
803
  end
759
804
 
760
805
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
@@ -59,7 +59,7 @@ module Samagotchi
59
59
  # /help's note per listed mid_turn (the web's command list says the same).
60
60
  MID_TURN_NOTES = { "anytime" => "mid-turn too", "depends" => "show: mid-turn too" }.freeze
61
61
 
62
- # The commands that ask before a change that restarts the prompt cache
62
+ # Commands that may ask before a change that restarts the prompt cache
63
63
  # (CacheConfirm): #run passes them its confirm flag.
64
64
  CONFIRMING = %i[thinking reasoning_history toolkit].freeze
65
65
 
@@ -287,8 +287,8 @@ module Samagotchi
287
287
  # How help and errors name an entry (the shell's "!" is "!<cmd>").
288
288
  def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
289
289
 
290
- # @param confirm [Boolean] a costly /thinking or /reasoning-history
291
- # change asks first (CacheConfirm): true for a person at a web tab or
290
+ # @param confirm [Boolean] a costly setting change asks first
291
+ # (CacheConfirm): true for a person at a web tab or
292
292
  # a TUI; false applies it at once (chi send, a delegate, a plugin,
293
293
  # chi -p)
294
294
  # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
@@ -304,7 +304,7 @@ module Samagotchi
304
304
  return nil unless entry&.handler
305
305
  return run_bundle_command(entry, text) unless entry.source == "core"
306
306
 
307
- # The two that may ask take the flag; the rest never ask.
307
+ # Only commands that may ask take the flag.
308
308
  return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
309
309
  # Who sent it goes to its handler per call, never through an ivar:
310
310
  # an anytime command runs on another thread against this instance.
@@ -54,8 +54,8 @@ module Samagotchi
54
54
  end
55
55
 
56
56
  cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
57
- if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
58
- step = CacheConfirm.confirm(NAME, words)
57
+ step = CacheConfirm.ask(line: "#{NAME} #{words}", cost: cost, changed: true, engine: @engine) if confirm && !yes
58
+ if step
59
59
  return [[change_line(before, after), reread_line(before, after), step.text].join("\n"), false, step]
60
60
  end
61
61
 
@@ -85,7 +85,10 @@ module Samagotchi
85
85
  # @param stop_reason [#call] the stop's reason (:signal: a signal to
86
86
  # chi, :session_stopped: a session stop, which the result then names
87
87
  # instead of the user)
88
- # @param env [Hash, nil] added to the command's environment
88
+ # @param env [Hash, nil] added to the command's environment, over
89
+ # chi's Ruby/Bundler variables scrubbed as task_create scrubs them
90
+ # (TaskRuntime.sanitized_spawn_env); chi's callers build it, so it
91
+ # may set those keys (task_create refuses them from the model)
89
92
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
90
93
  # nil reads execute.on_timeout
91
94
  def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
@@ -267,7 +270,7 @@ module Samagotchi
267
270
  # file, and each write lands at the end.
268
271
  ios = logs.map { |path| File.open(path, "a") }
269
272
  ios.each { |io| io.flock(File::LOCK_SH) }
270
- ProcessGroup.spawn(env || {}, *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
273
+ ProcessGroup.spawn(TaskRuntime.sanitized_spawn_env.merge(env || {}), *Shell.argv(TaskRuntime.wrapped_shell_command(command, exit_code_path)),
271
274
  chdir: cwd, in: File::NULL, out: ios[0], err: ios[1])
272
275
  ensure
273
276
  ios&.each(&:close)
@@ -445,9 +445,11 @@ module Samagotchi
445
445
  "trap #{Shellwords.escape(write)} EXIT\n#{command}\n"
446
446
  end
447
447
 
448
+ # chi's own Ruby/Bundler setup, kept out of a command's environment
449
+ # (execute and task_create both spawn with it): run under `bundle
450
+ # exec`, chi would otherwise hand a project's `ruby -e` or
451
+ # `bundle exec` its Gemfile (RUBYOPT=-rbundler/setup, BUNDLE_GEMFILE).
448
452
  def sanitized_spawn_env
449
- # Running from temporary directories can inherit Bundler-specific env
450
- # (for example RUBYOPT=-rbundler/setup) that breaks plain `ruby -e`.
451
453
  {
452
454
  "RUBYOPT" => sanitized_rubyopt,
453
455
  "RUBYLIB" => nil,
@@ -250,9 +250,17 @@ module Samagotchi
250
250
  return row.tap { |r| r.status = "not installed" } unless helper.installed?
251
251
 
252
252
  row.from = helper.app_version
253
+ if (problem = helper.launch_problem)
254
+ return failed(row, problem)
255
+ end
256
+
257
+ if helper.launch_newer?
258
+ return up_to_date(row).tap { |r| r.status += " (kept: chi #{helper.launch_version} set it up; this chi is older)" }
259
+ end
260
+
253
261
  if helper.stale?
254
262
  row.to = VERSION
255
- done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
263
+ done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade(keep_launch: true) { nil } }
256
264
  elsif helper.launch_outdated?
257
265
  helper.refresh_launch_file unless @dry_run
258
266
  up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.130.0"
4
+ VERSION = "0.131.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end