samagotchi 0.52.0 → 0.54.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -6,6 +6,7 @@ require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
8
  require_relative "client_id"
9
+ require_relative "analytics_file"
9
10
  require_relative "atomic_file"
10
11
  require_relative "config"
11
12
  require_relative "delivery"
@@ -64,6 +65,8 @@ module Samagotchi
64
65
  autoload :Worker, File.expand_path("worker", __dir__)
65
66
  # session_retention requires this file.
66
67
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
68
+ # session_continue requires this file.
69
+ autoload :SessionContinue, File.expand_path("session_continue", __dir__)
67
70
 
68
71
  class SessionManager
69
72
  # Raised when the interactive TUI owns the session: it runs its own Engine
@@ -119,35 +122,6 @@ module Samagotchi
119
122
  end
120
123
  end
121
124
 
122
- # A continue that can't happen (#continue_session). reason: :continued
123
- # (another session continues it already: +ids+ names it), :open_children
124
- # (open children it can't move to the new link: on an older chi's
125
- # worker or open in a chi REPL; +ids+ names them) or :folder_gone (its
126
- # folder isn't there).
127
- class ContinueRefused < StandardError
128
- attr_reader :session_id, :reason, :ids
129
-
130
- def initialize(session_id, reason, ids: [], detail: nil)
131
- @session_id = session_id
132
- @reason = reason
133
- @ids = ids
134
- super(continue_refused_message(detail))
135
- end
136
-
137
- private
138
-
139
- def continue_refused_message(detail)
140
- short = @session_id[0, 8]
141
- case @reason
142
- when :continued then "#{short} is continued already, by #{@ids.first[0, 8]}; continue that one (or last:#{short})"
143
- when :open_children
144
- "#{short} has delegates still open that can't move to the next link: #{detail}; " \
145
- "then continue (or wait for them, stop them or archive them)"
146
- else "#{short}'s folder #{detail} is gone"
147
- end
148
- end
149
- end
150
-
151
125
  # Spawn a new background session that processes the given prompt (or,
152
126
  # with none, waits idle for input).
153
127
  #
@@ -167,7 +141,7 @@ module Samagotchi
167
141
  # @param setup [SessionSetup] the session's own settings (chi
168
142
  # --llm-context; a fork and a continue copy their session's)
169
143
  # @param continues [String, nil] the session this one continues (a
170
- # session field; #continue_session)
144
+ # session field; SessionContinue)
171
145
  # @param note [String, nil] a context note the session starts with,
172
146
  # from +note_source+: queued before its worker spawns, so its first
173
147
  # turn sees it
@@ -222,227 +196,9 @@ module Samagotchi
222
196
  session
223
197
  end
224
198
 
225
- # How long #continue_session waits for the previous link's worker to
226
- # write its recap (IdleRecap's own request timeout).
227
- CONTINUE_RECAP_WAIT = 30.0
228
- # In the previous link's folder: one continue of a session at a time.
229
- CONTINUE_LOCK = "continue.lock"
230
- # How often a last:<id> continue looks again when another continue
231
- # moved the chain's end while it waited for the lock.
232
- CONTINUE_TRIES = 3
233
-
234
- # Start the next link of a chain: a new session that continues
235
- # +id_or_ref+ (Session#continues) in its folder, on its model (the name
236
- # it was typed as, so an alias holds) and with its own setup (SessionSetup),
237
- # starting with a context note from chi: the link and the previous
238
- # link's recap (SessionChain.note_text). The previous link is archived
239
- # first, with its finished delegates (archive_session's rules: a turn
240
- # running, a prompt queued or the step-limit question refuse; an idle
241
- # worker is stopped), so a refusal starts nothing. Its open delegates
242
- # (running, waiting, live, an unreported reply, at any depth below them)
243
- # move to the new link instead (ChildMove): their reports reach it.
244
- # Before that its live worker is asked for a recap and given up to
245
- # +recap_wait+ seconds to write it.
246
- # One continue of a session at a time (a lock in its folder): a double
247
- # click finds the first one's link and is refused; a last:<id> one
248
- # follows the chain to its new end instead.
249
- # @param id_or_ref [String] an id, a prefix, or last:<id> (SessionChain.resolve)
250
- # @param prompt [String, nil] its first message (nil: it starts idle)
251
- # @param title [String, nil] what the lists show before the first turn;
252
- # by default the prompt, else the previous link's preview
253
- # @return [Session] the new link (#model_warning as spawn_session's,
254
- # #moved_children: the delegates it took over)
255
- # @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
256
- # @raise [ContinueRefused] continued already, delegates open that can't
257
- # move, or its folder is gone
258
- # @raise [ArchiveRefused, OwnedByTUI] as archive_session
259
- def self.continue_session(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: CONTINUE_RECAP_WAIT,
260
- archive_wait: 5)
261
- sd = state_dir || Session.default_state_dir
262
- CONTINUE_TRIES.times do |try|
263
- id = SessionChain.resolve(id_or_ref, state_dir: sd)
264
- result = with_continue_lock(id, sd) do
265
- follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < CONTINUE_TRIES - 1
266
- next :moved if follow && SessionChain.next_of(id, state_dir: sd)
267
-
268
- continue_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
269
- archive_wait: archive_wait)
270
- end
271
- return result unless result == :moved
272
- end
273
- end
274
-
275
- private_class_method def self.with_continue_lock(id, state_dir, &)
276
- dir = Session.session_dir(id, state_dir: state_dir)
277
- FileUtils.mkdir_p(dir)
278
- File.open(File.join(dir, CONTINUE_LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
279
- lock.flock(File::LOCK_EX)
280
- yield
281
- end
282
- end
283
-
284
- private_class_method def self.continue_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
285
- previous = Session.load(id, state_dir: state_dir)
286
- plan = check_continue!(previous, state_dir)
287
- # archive_session's refusals that can be known now, before the wait.
288
- archivable!(id, state_dir, except: plan.move)
289
- recap_before_archive(id, state_dir, wait: recap_wait)
290
- # Again: a turn sent to it during the wait may have delegated (they
291
- # move too), or a delegate finished.
292
- plan = move_plan!(id, state_dir)
293
- already = [id, *descendant_ids(id, state_dir, except: plan.move)].select do |sid|
294
- ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
295
- end
296
- archived = archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)[:archived] - already
297
- begin
298
- spawn_continuation(previous, prompt: prompt, title: title, state_dir: state_dir, move: plan.move)
299
- rescue StandardError
300
- # Nothing started: the delegates go back, what this archived goes
301
- # back to the lists, and a new link saved before its worker failed
302
- # to spawn goes (left, it would hold the chain: "continued already",
303
- # last: to it).
304
- undo_move(id, state_dir, moved: plan.move)
305
- begin
306
- discard_failed_link(id, state_dir)
307
- ensure
308
- archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
309
- wake_for_rings(id, state_dir) unless plan.move.empty?
310
- end
311
- raise
312
- end
313
- end
314
-
315
- # The delegates a failed #continue_session moved, or began to (its
316
- # intent names them), go back to +id+.
317
- private_class_method def self.undo_move(id, state_dir, moved:)
318
- link = SessionChain.next_of(id, state_dir: state_dir) or return
319
- ids = ChildMove.read_intent(link, state_dir: state_dir)&.ids || moved
320
- ChildMove.apply(ids, from: link, to: id, state_dir: state_dir, undo: true)
321
- ChildMove.clear_intent(link, state_dir: state_dir)
322
- rescue StandardError => e
323
- Log.warn(:worker, "continue_undo_move_failed", sid: id, error: e.class.name, msg: e.message)
324
- end
325
-
326
- # The link a failed #continue_session saved (no other continues +id+:
327
- # check_continue! made sure under the lock): its worker stopped if one
328
- # started, then the rings a moved delegate wrote into it just before
329
- # the undo go back to +id+, then the link goes. A worker that outlives
330
- # the stop is raised (DeleteRefused): the link stays, and the caller
331
- # hears it.
332
- private_class_method def self.discard_failed_link(id, state_dir)
333
- orphan = SessionChain.next_of(id, state_dir: state_dir) or return
334
- if refuse_tui!(orphan, state_dir: state_dir) && !stop_session(orphan, state_dir: state_dir, wait: 10)
335
- raise DeleteRefused.new(orphan, :still_stopping)
336
- end
337
-
338
- ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
339
- delete_session(orphan, state_dir: state_dir)
340
- rescue DeleteRefused
341
- raise
342
- rescue StandardError => e
343
- Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
344
- end
345
-
346
- # A failed continue's previous link, back in the lists, with rings that
347
- # came in meanwhile: wake it for them, as the ring's own wake would have.
348
- private_class_method def self.wake_for_rings(id, state_dir)
349
- return if SessionInbox.find_ring_files(Session.session_dir(id, state_dir: state_dir)).empty?
350
-
351
- wake_for_report(id, state_dir: state_dir)
352
- rescue StandardError => e
353
- Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
354
- end
355
-
356
- # The new link, holding off any worker but its own (ChildMove.starting?)
357
- # while +move+ goes to it.
358
- private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
359
- recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
360
- start = prompt.to_s.strip.empty? ? nil : prompt
361
- title = previous.first_preview if title.to_s.strip.empty? && start.nil?
362
- before_spawn = lambda do |link|
363
- ChildMove.mark_starting(link.id, state_dir: state_dir)
364
- ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
365
- end
366
- link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
367
- model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
368
- continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
369
- note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
370
- link.moved_children = move
371
- link
372
- end
373
-
374
- # The refusals that come before anything changes: continued already (no
375
- # forks in a chain), an open delegate that can't move to the new link
376
- # (ChildMove.plan), the folder gone (the worker would run elsewhere), a
377
- # model whose host config.yml no longer has.
378
- # @return [ChildMove::Plan]
379
- # @raise [ContinueRefused, ModelProfile::MissingModel]
380
- private_class_method def self.check_continue!(previous, state_dir)
381
- if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
382
- raise ContinueRefused.new(previous.id, :continued, ids: [following])
383
- end
384
-
385
- plan = move_plan!(previous.id, state_dir)
386
- # Its model's host, as spawn_session checks it: before the archive
387
- # stops the previous link's worker.
388
- ModelProfile.check_host!(ModelProfile.required_model_name(previous.model_typed || previous.model_name))
389
- dir = previous.working_directory.to_s
390
- raise ContinueRefused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
391
-
392
- plan
393
- end
394
-
395
- # A child still open below a session (#open_children); why: running,
396
- # waiting, live, unreported reply, or why it can't move (ChildMove).
397
- OpenChild = Data.define(:id, :short_id, :why)
398
-
399
- # The delegates a continue of +id+ moves (ChildMove.plan).
400
- # @return [ChildMove::Plan]
401
- # @raise [ContinueRefused] for open ones it can't move
402
- private_class_method def self.move_plan!(id, state_dir)
403
- plan = ChildMove.plan(id, open: open_children(id, state_dir), state_dir: state_dir)
404
- return plan if plan.refuse.empty?
405
-
406
- raise ContinueRefused.new(id, :open_children, ids: plan.refuse.map(&:id),
407
- detail: plan.refuse.map { |c| "#{c.short_id} (#{c.why})" }.join(", "))
408
- end
409
-
410
- # The unarchived children, at any depth (the archive's cascade), of the
411
- # session still running, waiting for an answer, with a live worker, or
412
- # (a delegate) with a reply its parent wasn't given (ChildrenStatus).
413
- # @return [Array<OpenChild>]
414
- def self.open_children(id, state_dir)
415
- require_relative "children_status"
416
- [id, *descendant_ids(id, state_dir)].flat_map { |node| ChildrenStatus.of(node, state_dir: state_dir) }.uniq(&:id)
417
- .filter_map do |child|
418
- why = if %w[waiting running].include?(child.state) then child.state
419
- elsif child.live then "live"
420
- elsif child.delegate && child.last_reply && !child.reported then "unreported reply"
421
- end
422
- why && OpenChild.new(id: child.id, short_id: child.short_id, why: why)
423
- end
424
- end
425
-
426
- # Ask the session's live worker for a recap now (Bridge POST /recap:
427
- # IdleRecap#request_now) and wait, up to +wait+ seconds, for recap.json
428
- # to change. A worker that is gone wrote its recap when it left; one
429
- # that has nothing new to say, too short a conversation, or recaps off
430
- # answers at once. Best effort: the continue goes on without.
431
- private_class_method def self.recap_before_archive(id, state_dir, wait:)
432
- return unless worker_live?(id, state_dir: state_dir)
433
-
434
- dir = Session.session_dir(id, state_dir: state_dir)
435
- client = BridgeClient.discover(id, session_dir: dir) or return
436
- before = RecapStore.read(dir)
437
- reply = client.request_recap
438
- asked = reply.status == 200 && reply.json.is_a?(Hash) ? reply.json["request"] : nil
439
- return unless %w[started in_flight].include?(asked)
440
-
441
- BridgeClient.poll(wait, interval: 0.2) { RecapStore.read(dir) != before }
442
- rescue StandardError => e
443
- Log.info(:worker, "continue_recap_failed", sid: id, error: e.class.name, msg: e.message)
444
- nil
445
- end
199
+ # Start the next link of a chain (SessionContinue.run, which has the
200
+ # rules and raises SessionContinue::Refused).
201
+ def self.continue_session(...) = SessionContinue.run(...)
446
202
 
447
203
  # Build the opts hash passed to Process.spawn for a forked worker. The
448
204
  # child inherits this process's ENV and reads config.yml itself (as it
@@ -636,10 +392,11 @@ module Samagotchi
636
392
  end
637
393
 
638
394
  # The session and its descendants, with their owners, when they can be
639
- # archived now (#archive_refusal for each), else the refusal.
395
+ # archived now (#archive_refusal for each), else the refusal. Public for
396
+ # SessionContinue, which checks before its recap wait.
640
397
  # @return [Array(Array<String>, Hash)] the ids, and id => OwnerLock::Owner or nil
641
398
  # @raise [ArchiveRefused, OwnedByTUI]
642
- private_class_method def self.archivable!(id, state_dir, except: [])
399
+ def self.archivable!(id, state_dir, except: [])
643
400
  raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: state_dir).scratch
644
401
 
645
402
  tree = [id, *descendant_ids(id, state_dir, except: except)]
@@ -974,7 +731,7 @@ module Samagotchi
974
731
  # session keeps. "pid" is the pid file workers wrote until 2026-10-01:
975
732
  # nothing writes or reads it now, but a folder from before still has one
976
733
  # and is still empty.
977
- EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, "analytics.json"].freeze
734
+ EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, AnalyticsFile::NAME].freeze
978
735
  EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
979
736
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
980
737
 
@@ -1058,17 +815,48 @@ module Samagotchi
1058
815
  path = File.join(dir, name)
1059
816
  if EMPTY_SKELETON_DIRS.include?(name)
1060
817
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
1061
- elsif name == "analytics.json"
1062
- Array(JSON.parse(File.read(path))["turn_records"]).empty?
818
+ elsif name == AnalyticsFile::NAME
819
+ analytics_empty?(path)
1063
820
  # Cards and notices from before any turn (Bridge::CardStore).
1064
821
  elsif name == "cards.json"
1065
- JSON.parse(File.read(path))["turns"].to_i.zero?
822
+ cards_empty?(path)
1066
823
  else
1067
824
  EMPTY_SKELETON_FILES.include?(name)
1068
825
  end
1069
826
  end
1070
827
  end
1071
828
 
829
+ # A folder's analytics.json is empty only when it is a JSON object with
830
+ # an empty turn_records array. Anything unreadable, not JSON, not an
831
+ # object, the wrong type, or turn_records the wrong shape reads as
832
+ # "not empty": a broken analytics keeps the folder instead of crashing
833
+ # the retention sweep (or of a wrong-shape one reading as empty).
834
+ def self.analytics_empty?(path)
835
+ data = read_json(path)
836
+ turns = data.is_a?(Hash) ? data["turn_records"] : nil
837
+ turns.is_a?(Array) && turns.empty?
838
+ end
839
+
840
+ # A folder's cards.json is empty only when it is a JSON object whose
841
+ # turns is an integer zero. Anything unreadable, not JSON, not an
842
+ # object, the wrong type, or turns not an integer reads as "not empty":
843
+ # a broken or wrong-shape cards file keeps the folder instead of
844
+ # raising (Array#/Hash#to_i) or reading as empty.
845
+ def self.cards_empty?(path)
846
+ data = read_json(path)
847
+ turns = data.is_a?(Hash) ? data["turns"] : nil
848
+ turns.is_a?(Integer) && turns.zero?
849
+ end
850
+
851
+ # A session-dir JSON file's contents, or nil when it is missing,
852
+ # unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
853
+ # broken file reads as none, so its folder is kept, not deleted.
854
+ def self.read_json(path)
855
+ JSON.parse(File.read(path))
856
+ rescue JSON::ParserError, SystemCallError
857
+ nil
858
+ end
859
+
1072
860
  # Delete one session: its <id>.json, the whole <id>/ directory
1073
861
  # (history sidecars, input, output, notes, images) and its attached
1074
862
  # context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
@@ -5,6 +5,7 @@ require "monitor"
5
5
  require "time"
6
6
  require "fileutils"
7
7
  require "securerandom"
8
+ require_relative "analytics_file"
8
9
  require_relative "atomic_file"
9
10
  require_relative "token_usage"
10
11
  require_relative "model_price"
@@ -172,8 +173,8 @@ module Samagotchi
172
173
  # @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
173
174
  # @return [SavedSummary, nil]
174
175
  def self.saved_summary(session_dir, budget_tokens: nil)
175
- data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
176
- return nil unless data.is_a?(Hash)
176
+ data = AnalyticsFile.read(session_dir)
177
+ return nil unless data
177
178
 
178
179
  tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
179
180
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
@@ -182,7 +183,7 @@ module Samagotchi
182
183
  reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
183
184
  cost_estimate_sum: count.call("cost_estimate_sum"),
184
185
  memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
185
- rescue JSON::ParserError, SystemCallError, TypeError
186
+ rescue TypeError
186
187
  nil
187
188
  end
188
189
 
@@ -217,10 +218,10 @@ module Samagotchi
217
218
  def self.saved_tool_records(session_dir, turn_id)
218
219
  return [] if turn_id.to_s.empty?
219
220
 
220
- records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
221
+ records = AnalyticsFile.read(session_dir)&.dig("tool_records")
221
222
  Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
222
223
  .sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
223
- rescue JSON::ParserError, SystemCallError, TypeError
224
+ rescue TypeError
224
225
  []
225
226
  end
226
227
 
@@ -234,9 +235,9 @@ module Samagotchi
234
235
  def self.saved_turn_record(session_dir, turn_id)
235
236
  return nil if turn_id.to_s.empty?
236
237
 
237
- records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["turn_records"]
238
+ records = AnalyticsFile.read(session_dir)&.dig("turn_records")
238
239
  Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
239
- rescue JSON::ParserError, SystemCallError, TypeError
240
+ rescue TypeError
240
241
  nil
241
242
  end
242
243
 
@@ -422,8 +423,7 @@ module Samagotchi
422
423
  # (XDG) location; passing an explicit nil would override it and break
423
424
  # File.join.
424
425
  FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
425
- path = File.join(dir, "analytics.json")
426
- AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
426
+ AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
427
427
  @mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
428
428
  true
429
429
  rescue StandardError
@@ -824,6 +824,8 @@ module Samagotchi
824
824
 
825
825
  called_as = event.dig(:activity, :called_as)
826
826
  active[:called_as] = called_as.to_s if called_as
827
+ # A grep that found nothing (ok): the reloaded web row says "no match".
828
+ active[:no_match] = true if event.dig(:activity, :no_match)
827
829
  finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
828
830
  end
829
831
  end
@@ -831,7 +833,7 @@ module Samagotchi
831
833
  # Caller holds the mutex.
832
834
  def finish_tool_record(active, status, waited_ms: 0)
833
835
  record = active.slice(
834
- :id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
836
+ :id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
835
837
  ).merge(
836
838
  status: status,
837
839
  completed_at: now.iso8601(3),
@@ -977,11 +979,8 @@ module Samagotchi
977
979
  # broken file, or an older shape: nothing, or zeros where keys are
978
980
  # missing). Caller holds the mutex.
979
981
  def load_persisted
980
- path = File.join(session_dir(@session_id, @state_dir), "analytics.json")
981
- return unless File.file?(path)
982
-
983
- prior = JSON.parse(File.read(path))
984
- return unless prior.is_a?(Hash)
982
+ prior = AnalyticsFile.read(session_dir(@session_id, @state_dir))
983
+ return unless prior
985
984
 
986
985
  loaded_turns = loaded_records(prior["turn_records"])
987
986
  @turn_records = loaded_turns + @turn_records
@@ -1004,12 +1003,10 @@ module Samagotchi
1004
1003
  @memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
1005
1004
  # The window last seen, until this process's first generation reports.
1006
1005
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
1007
- if window["window_tokens"] && @context_window_tokens.nil?
1008
- @context_window_tokens = window["window_tokens"]
1009
- @context_window_source = window["window_source"]
1010
- end
1011
- rescue JSON::ParserError, SystemCallError
1012
- nil
1006
+ return unless window["window_tokens"] && @context_window_tokens.nil?
1007
+
1008
+ @context_window_tokens = window["window_tokens"]
1009
+ @context_window_source = window["window_source"]
1013
1010
  end
1014
1011
 
1015
1012
  def symbolized(hash)
@@ -5,6 +5,7 @@ require_relative "session"
5
5
  require_relative "session_manager"
6
6
  require_relative "session_retention"
7
7
  require_relative "project_scope"
8
+ require_relative "analytics_file"
8
9
  require_relative "session_metrics"
9
10
  require_relative "recap_store"
10
11
  require_relative "session_delete_command"
@@ -337,10 +338,10 @@ module Samagotchi
337
338
  return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
338
339
  end
339
340
  end
340
- return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
341
+ return [nil, false, nil] unless File.file?(Samagotchi::AnalyticsFile.path(dir))
341
342
 
342
343
  snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
343
- note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
344
+ note = live_worker?(session.id) ? "(no live worker answered; from the saved #{Samagotchi::AnalyticsFile::NAME})" : nil
344
345
  [snapshot, false, note]
345
346
  end
346
347
 
@@ -1268,7 +1268,8 @@ module Samagotchi
1268
1268
  # (it ended first).
1269
1269
  def join_tool_line(part, duration_ms: nil)
1270
1270
  output = part[:output]
1271
- status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
1271
+ command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1272
+ status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
1272
1273
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1273
1274
  params: part[:params], status: status,
1274
1275
  description: ToolView.description_title(part[:view] || {}) }
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # (no UI: the same text from the CLI).
21
21
  # @return [String] "(no metrics yet)" without a snapshot
22
22
  def self.format_session_metrics(snapshot)
23
- (@formatter ||= Object.new.extend(Formatting)).send(:format_session_metrics, snapshot)
23
+ (@formatter ||= Object.new.extend(Formatting)).format_session_metrics(snapshot)
24
24
  end
25
25
 
26
26
  def format_tool_activity_line(activity, duration_ms: nil)
@@ -543,6 +543,8 @@ module Samagotchi
543
543
  value.length > width ? value[0, width] : value
544
544
  end
545
545
 
546
+ public
547
+
546
548
  # Render the analytics snapshot as a compact, user-facing report. Raw event
547
549
  # logs (debug-only) are intentionally excluded; this surface is for the REPL
548
550
  # and `chi sessions stats` (the module function above).
@@ -607,6 +609,8 @@ module Samagotchi
607
609
  lines.join("\n")
608
610
  end
609
611
 
612
+ private
613
+
610
614
  # " (25.0% of the 64000 budget)" when the session's llm_context budget
611
615
  # is set and smaller than the window, else " (12.5%)": the live meter
612
616
  # counts against the smaller of the two (ContextStatus#counted_against),
@@ -238,6 +238,10 @@ module Samagotchi
238
238
  # --non-interactive with no --prompt is a harmless no-op exit: build
239
239
  # nothing and return (no transient session, no banner).
240
240
  return if @non_interactive && @prompt.nil?
241
+ # A UI-only command (/stats, /exit …) isn't run without the REPL, and
242
+ # isn't a prompt either: refused as chi send refuses it, before any
243
+ # session is made (bin/chi exits 1).
244
+ return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
241
245
 
242
246
  session = @resume_session || @engine.store_model!(Session.new_session(
243
247
  mode: "assist",
@@ -269,6 +273,16 @@ module Samagotchi
269
273
  return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
270
274
 
271
275
  if @prompt && @non_interactive
276
+ # A /cut TEXT / /queue TEXT prompt runs its text as the turn's
277
+ # prompt, the same as the idle REPL does (Formatting#steer_choice);
278
+ # a bare /cut / /queue (no text) is refused above as a UI command,
279
+ # so only the text here. The images are the text's, not the words'.
280
+ one_shot_prompt =
281
+ if (choice = steer_choice(@prompt)) && !choice.last.empty?
282
+ choice.last
283
+ else
284
+ @prompt
285
+ end
272
286
  # Headless / CI mode: run directly without TTY rendering.
273
287
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
274
288
  # Retry lines and hook notices on stderr; stdout gets the answer below.
@@ -277,11 +291,11 @@ module Samagotchi
277
291
  begin
278
292
  result = @engine.run_turn(
279
293
  session,
280
- @prompt,
294
+ one_shot_prompt,
281
295
  on_event: sink,
282
296
  max_iterations: IterationLimit.for(no_interrupt: true),
283
297
  cancel_controller: nil,
284
- images: ImageInput.extract(@prompt)
298
+ images: ImageInput.extract(one_shot_prompt)
285
299
  )
286
300
  rescue LLM::ProviderError, ImageStore::Error => e
287
301
  return one_shot_failed(session, e)
@@ -348,7 +362,7 @@ module Samagotchi
348
362
  # so the session is kept and `chi --resume` takes it up again.
349
363
  # @return [Symbol] :turn_failed
350
364
  def one_shot_failed(session, error)
351
- warn "Error: #{error.respond_to?(:summary) ? error.summary : error.message}"
365
+ warn "Error: #{@engine.failure_summary(error)}"
352
366
  if !@scratch && Session.exist?(session.id)
353
367
  warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
354
368
  end
@@ -371,6 +385,14 @@ module Samagotchi
371
385
  :turn_failed if result.status == :error
372
386
  end
373
387
 
388
+ # `chi -p /stats --non-interactive`: why it doesn't run, on stderr.
389
+ # @return [Symbol] :turn_failed
390
+ def refuse_one_shot_ui_command
391
+ name = SessionCommands.ui_only_entry(@prompt).name
392
+ warn "chi: refused: #{SessionCommands.ui_only_refusal(name, @resume_session&.id, runner: "--non-interactive")}"
393
+ :turn_failed
394
+ end
395
+
374
396
  # Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
375
397
  # a cancel note in the session (it saves only a failed turn), so save
376
398
  # it here: `chi --resume` takes it up again. A scratch session goes in