samagotchi 0.75.0 → 0.77.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +15 -4
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +43 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/answer_command.rb +5 -5
  17. data/lib/samagotchi/bridge.rb +3 -3
  18. data/lib/samagotchi/bundle_command.rb +52 -39
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  26. data/lib/samagotchi/child_reports.rb +1 -1
  27. data/lib/samagotchi/desktop/macos.rb +1 -2
  28. data/lib/samagotchi/effective_model.rb +5 -0
  29. data/lib/samagotchi/engine.rb +105 -16
  30. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  31. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  32. data/lib/samagotchi/kernel_loop.rb +13 -10
  33. data/lib/samagotchi/llm_context_override.rb +8 -6
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  35. data/lib/samagotchi/memory_bundle/builder.rb +13 -14
  36. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  37. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  38. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  39. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  43. data/lib/samagotchi/message_parts.rb +272 -0
  44. data/lib/samagotchi/model_price.rb +4 -3
  45. data/lib/samagotchi/parent_report.rb +30 -22
  46. data/lib/samagotchi/paths.rb +15 -1
  47. data/lib/samagotchi/pending_question.rb +49 -0
  48. data/lib/samagotchi/question_desk.rb +39 -32
  49. data/lib/samagotchi/relay.rb +8 -9
  50. data/lib/samagotchi/relay_verifier.rb +2 -2
  51. data/lib/samagotchi/relay_watcher.rb +2 -2
  52. data/lib/samagotchi/reply_wait.rb +4 -4
  53. data/lib/samagotchi/row_refs.rb +1 -1
  54. data/lib/samagotchi/session.rb +22 -14
  55. data/lib/samagotchi/session_commands.rb +5 -0
  56. data/lib/samagotchi/session_manager.rb +1 -2
  57. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  58. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  59. data/lib/samagotchi/session_metrics.rb +118 -83
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  62. data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
  63. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  64. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  65. data/lib/samagotchi/terminal_ui.rb +5 -3
  66. data/lib/samagotchi/tool_output_cut.rb +1 -1
  67. data/lib/samagotchi/tool_response.rb +13 -13
  68. data/lib/samagotchi/tool_row_fields.rb +1 -1
  69. data/lib/samagotchi/tool_runner.rb +16 -9
  70. data/lib/samagotchi/toolkit.rb +9 -8
  71. data/lib/samagotchi/toolkit_command.rb +2 -2
  72. data/lib/samagotchi/tools/builtins.rb +1 -5
  73. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  74. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  75. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  76. data/lib/samagotchi/version.rb +1 -1
  77. data/lib/samagotchi/web/app.rb +5 -2
  78. data/lib/samagotchi/web/public/app.js +21 -58
  79. data/lib/samagotchi/web/public/chains.js +1 -1
  80. data/lib/samagotchi/web/public/composer.js +9 -5
  81. data/lib/samagotchi/web/public/ctx.js +84 -11
  82. data/lib/samagotchi/web/public/index.html +32 -36
  83. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  84. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  85. data/lib/samagotchi/web/public/question_cards.js +1 -1
  86. data/lib/samagotchi/web/public/session_state.js +3 -0
  87. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  88. data/lib/samagotchi/web/public/stream_handlers.js +38 -3
  89. data/lib/samagotchi/web/public/timing.js +11 -2
  90. data/lib/samagotchi/web/public/toasts.js +69 -0
  91. metadata +8 -2
  92. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -7,6 +7,7 @@ require "time"
7
7
 
8
8
  require_relative "client_id"
9
9
  require_relative "log"
10
+ require_relative "pending_question"
10
11
  require_relative "tools/ask_user_question"
11
12
  require_relative "guardrails/parent_approvals"
12
13
  require_relative "guardrails/parent_continue"
@@ -87,6 +88,8 @@ module Samagotchi
87
88
  # A Monitor: re-entered by the specs from the same thread.
88
89
  @lock = Monitor.new
89
90
  @cv = @lock.new_cond
91
+ # The open question (PendingQuestion), nil with none; replaced, never
92
+ # changed in place (#annotate, #cancel make a new one with #with).
90
93
  @pending = nil
91
94
  @answer = nil
92
95
  # Why the open question was cancelled (#cancel), for the sync path.
@@ -99,9 +102,10 @@ module Samagotchi
99
102
  @shelved = nil
100
103
  end
101
104
 
102
- # @return [Hash, nil] current pending question (thread-safe copy)
105
+ # @return [PendingQuestion, nil] the question pending now (a value: it
106
+ # never changes once read)
103
107
  def pending
104
- @lock.synchronize { @pending&.dup }
108
+ @lock.synchronize { @pending }
105
109
  end
106
110
 
107
111
  # Ask the model's question (ask_user_question): the kernel's
@@ -127,8 +131,9 @@ module Samagotchi
127
131
  # Open a question for the UIs and wait for its answer. Emits
128
132
  # :question_requested, persists it to the session, and BLOCKS until
129
133
  # answer / cancel wakes it (or the turn is cancelled).
130
- # The fields go to pending as given (no cleaning), extra keys
131
- # included, so a caller can add its own (kind:, approval:).
134
+ # The fields become the PendingQuestion as given (no cleaning): any of
135
+ # its members (kind:, approval:, …); a key it doesn't have raises
136
+ # ArgumentError.
132
137
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
133
138
  # @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
134
139
  # question waits (outside the lock): a String closes the question with
@@ -155,7 +160,7 @@ module Samagotchi
155
160
  # @param on_superseded_close [#call, nil] called when a question that
156
161
  # superseded it closes (its poster posts it again if it still
157
162
  # stands); nil: posted again as it was
158
- # @return [Hash, nil] the pending question; nil when it waits for another
163
+ # @return [PendingQuestion, nil] the pending question; nil when it waits for another
159
164
  def post(fields, on_answer:, on_superseded_close: nil)
160
165
  standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
161
166
  shelved = @lock.synchronize do
@@ -179,7 +184,7 @@ module Samagotchi
179
184
  withdrawn = @lock.synchronize do
180
185
  @shelved = nil if id.nil?
181
186
  standing = @standing
182
- next nil unless standing && @pending && @pending[:id] == standing[:id]
187
+ next nil unless standing && @pending && @pending.id == standing[:id]
183
188
  next nil if id && standing[:id].to_s != id.to_s
184
189
 
185
190
  @standing = nil
@@ -195,7 +200,7 @@ module Samagotchi
195
200
 
196
201
  private def ask(fields, watch)
197
202
  pending = publish(fields)
198
- id = pending[:id]
203
+ id = pending.id
199
204
 
200
205
  # If a synchronous UI handler is registered (TUI), invoke it inline on the
201
206
  # SAME thread that called request_question (TerminalUI's REPL thread is the
@@ -204,7 +209,7 @@ module Samagotchi
204
209
  # any future UI that registers a sync handler gets the same guarantee.
205
210
  if @sync_handler
206
211
  begin
207
- sync_res = @sync_handler.call(pending.dup)
212
+ sync_res = @sync_handler.call(pending.to_h)
208
213
  # Handler may have called answer_question or returned a hash/string.
209
214
  # Decided under the lock; saved and announced after it (never emit
210
215
  # holding the question lock: a snapshot holds the event lock and
@@ -298,12 +303,12 @@ module Samagotchi
298
303
  answer = @lock.synchronize do
299
304
  pending = @pending
300
305
  raise NotPending, "no pending question" unless pending
301
- raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
306
+ raise NotPending, "id mismatch" unless pending.id.to_s == id.to_s
302
307
  # First responder wins: after the first answer the turn thread clears
303
308
  # @pending in a later lock block, so a second UI's answer can
304
309
  # land in between and must not overwrite the first.
305
310
  raise NotPending, "question already answered" if @answer
306
- raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
311
+ raise NotPending, "question #{pending.status}" unless pending.status.to_s == "pending"
307
312
 
308
313
  answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
309
314
  if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
@@ -338,15 +343,15 @@ module Samagotchi
338
343
  def annotate(id, relayed_to:, reason: nil)
339
344
  marked = @lock.synchronize do
340
345
  pending = @pending
341
- next nil unless pending && pending[:id].to_s == id.to_s
342
- next nil if @answer || pending[:status].to_s != "pending"
346
+ next nil unless pending && pending.id.to_s == id.to_s
347
+ next nil if @answer || pending.status.to_s != "pending"
343
348
 
344
- relayed_to ? pending[:relayed_to] = relayed_to : pending.delete(:relayed_to)
349
+ @pending = pending = pending.with(relayed_to: relayed_to)
345
350
  if session
346
- session.pending_question = pending.dup
351
+ session.pending_question = pending
347
352
  begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
348
353
  end
349
- pending[:id]
354
+ pending.id
350
355
  end
351
356
  return false unless marked
352
357
 
@@ -372,16 +377,16 @@ module Samagotchi
372
377
  cancelled_id = @lock.synchronize do
373
378
  pending = @pending
374
379
  next unless pending
375
- next if id && pending[:id].to_s != id.to_s
376
- next if @answer || pending[:status].to_s != "pending"
380
+ next if id && pending.id.to_s != id.to_s
381
+ next if @answer || pending.status.to_s != "pending"
377
382
  # Its poster closes it; nothing waits on a cancel.
378
- raise NotDismissable, "answer #{Array(pending[:options]).join(" or ")}" if @standing && id
383
+ raise NotDismissable, "answer #{Array(pending.options).join(" or ")}" if @standing && id
379
384
  next if @standing
380
385
 
381
- pending[:status] = "cancelled"
386
+ @pending = pending.with(status: "cancelled")
382
387
  @cancel_reason = reason.to_s
383
388
  @cv.broadcast
384
- pending[:id]
389
+ pending.id
385
390
  end
386
391
  return id.nil? && self.pending.nil? unless cancelled_id
387
392
 
@@ -420,21 +425,23 @@ module Samagotchi
420
425
  # :question_requested to every UI. Observers that stash the event (e.g.
421
426
  # TerminalUI handle_question_event) discard it as stale if a sync
422
427
  # handler answers and clears it first (drain_pending_question?).
423
- # @return [Hash] the pending question
428
+ # The event carries it as a Hash (PendingQuestion#to_h), as every UI
429
+ # and the Bridge's stream read it.
430
+ # @return [PendingQuestion] the pending question
424
431
  # @param standing [Hash, nil] a standing question's record (#post)
425
432
  def publish(fields, standing: nil)
426
- pending = { id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
433
+ pending = PendingQuestion.new(id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3))
427
434
  @lock.synchronize do
428
435
  @pending = pending
429
436
  @answer = nil
430
437
  @cancel_reason = nil
431
- @standing = standing&.merge(id: pending[:id])
438
+ @standing = standing&.merge(id: pending.id)
432
439
  end
433
440
  if session
434
- session.pending_question = pending.dup
441
+ session.pending_question = pending
435
442
  begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
436
443
  end
437
- event = { type: :question_requested, pending_question: pending }
444
+ event = { type: :question_requested, pending_question: pending.to_h }
438
445
  event[:standing] = true if standing
439
446
  emit(event)
440
447
  pending
@@ -489,11 +496,11 @@ module Samagotchi
489
496
  # @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
490
497
  # @raise [ArgumentError, Refused]
491
498
  def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
492
- opts = Array(pending[:options])
499
+ opts = Array(pending.options)
493
500
  invalid = sel.reject { |v| opts.include?(v) }
494
501
  raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
495
- raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
496
- raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
502
+ raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending.multi_select && sel.size > 1
503
+ raise ArgumentError, "selection required" if pending.multi_select == false && sel.empty? && fm.nil?
497
504
 
498
505
  indices = sel.map { |v| opts.index(v) }
499
506
  # The one whose answer settles the approval (Approval.settle, by index).
@@ -501,7 +508,7 @@ module Samagotchi
501
508
  reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
502
509
  raise Refused, reason if reason
503
510
  end
504
- answer = { id: pending[:id].to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
511
+ answer = { id: pending.id.to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
505
512
  answer[:by] = "parent_agent" if parent_agent
506
513
  answer
507
514
  end
@@ -513,7 +520,7 @@ module Samagotchi
513
520
  loop do
514
521
  done = @lock.synchronize do
515
522
  next true if @answer || cancel_controller&.cancelled?
516
- next true if @pending.nil? || @pending[:status] != "pending"
523
+ next true if @pending.nil? || @pending.status != "pending"
517
524
 
518
525
  # Wait with timeout to check cancel; 0.2s matches reminder poll
519
526
  @cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
@@ -527,9 +534,9 @@ module Samagotchi
527
534
  next unless reason
528
535
 
529
536
  closed = @lock.synchronize do
530
- next false if @answer || @pending.nil? || @pending[:status] != "pending"
537
+ next false if @answer || @pending.nil? || @pending.status != "pending"
531
538
 
532
- @pending[:status] = "cancelled"
539
+ @pending = @pending.with(status: "cancelled")
533
540
  true
534
541
  end
535
542
  return reason if closed
@@ -18,29 +18,28 @@ module Samagotchi
18
18
  module_function
19
19
 
20
20
  # @param child [Session] the child as loaded (its id, its first prompt)
21
- # @param pending [Hash] the child's pending question (string keys inside
22
- # when it came from a saved session)
21
+ # @param pending [PendingQuestion] the child's pending question (string
22
+ # keys inside its approval and relay when it came from a saved session)
23
23
  # @param relay_id [String] RelayDesk's id
24
24
  # @param more [Integer] other delegates' approvals waiting behind this one
25
25
  # @return [Hash] Engine#open_question fields
26
26
  def card(child, pending, relay_id:, more: 0)
27
- pending = deep_symbolize(pending)
28
- facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
27
+ facts = pending.approval.is_a?(Hash) ? deep_symbolize(pending.approval) : {}
29
28
  short = child.id.to_s[0, 8]
30
- inner = pending[:relay].is_a?(Hash) ? pending[:relay] : nil
29
+ inner = pending.relay.is_a?(Hash) ? deep_symbolize(pending.relay) : nil
31
30
  chain = inner ? [*Array(inner[:chain]), short] : [short]
32
- asked = inner ? inner[:asked].to_s : pending[:question].to_s
31
+ asked = inner ? inner[:asked].to_s : pending.question.to_s
33
32
  task = inner ? inner[:task].to_s : task_of(child)
34
33
  who = chain.reverse.join(" → ")
35
34
  {
36
35
  question: question_text(who, task, asked),
37
- options: relabel(Array(pending[:options]), facts[:scopes], who),
36
+ options: relabel(Array(pending.options), facts[:scopes], who),
38
37
  header: header(who, more),
39
38
  multi_select: false,
40
- allow_freeform: pending.key?(:allow_freeform) ? !!pending[:allow_freeform] : true,
39
+ allow_freeform: pending.allow_freeform.nil? || !!pending.allow_freeform,
41
40
  kind: Guardrails::Approval::KIND,
42
41
  approval: facts,
43
- relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending[:id].to_s,
42
+ relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending.id.to_s,
44
43
  task: task, chain: chain, asked: asked, more: more.to_i }
45
44
  }
46
45
  end
@@ -70,9 +70,9 @@ module Samagotchi
70
70
  # of range; [] for a freeform-only deny.
71
71
  def labels(indices, question_id)
72
72
  pending = @engine.pending_question
73
- return [] unless pending && pending[:id].to_s == question_id
73
+ return [] unless pending && pending.id.to_s == question_id
74
74
 
75
- options = Array(pending[:options])
75
+ options = Array(pending.options)
76
76
  indices = Array(indices)
77
77
  return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
78
78
 
@@ -37,8 +37,8 @@ module Samagotchi
37
37
  end
38
38
 
39
39
  def relayed?(pending, question_id, relay_id)
40
- relay = pending && pending[:relayed_to]
41
- return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
40
+ relay = pending&.relayed_to
41
+ return false unless relay.is_a?(Hash) && pending.id.to_s == question_id.to_s
42
42
 
43
43
  (relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
44
44
  end
@@ -30,7 +30,7 @@ module Samagotchi
30
30
  # @!attribute text [String, nil] the reply (:done) or the worker's error
31
31
  # (:error)
32
32
  # @!attribute file [String, nil] the reply's filename, the next cursor
33
- # @!attribute question [Hash, nil] the pending question
33
+ # @!attribute question [PendingQuestion, nil] the pending question
34
34
  # (:waiting_for_answer)
35
35
  # @!attribute outcome [String, nil] how the turn ended when it left no
36
36
  # reply (:no_reply): "failed", "canceled", "completed" (empty),
@@ -152,7 +152,7 @@ module Samagotchi
152
152
  # lists' rule): one a dead worker left waits for no one, and a chi
153
153
  # REPL's is answered there.
154
154
  pending = session.pending_question
155
- if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
155
+ if pending && !(baseline && pending.id == baseline[:question_id]) &&
156
156
  (turn.nil? || own_question?(session, turn, claimed)) &&
157
157
  session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
158
158
  return Result.new(status: :waiting_for_answer, question: pending, session: session)
@@ -217,7 +217,7 @@ module Samagotchi
217
217
  # its messages, the question pending then, and when its last turn
218
218
  # ended, so a turn that ends before the first look still ends the wait.
219
219
  # @return [Hash]
220
- def baseline_of(session, question_id: session.pending_question&.dig(:id))
220
+ def baseline_of(session, question_id: session.pending_question&.id)
221
221
  { messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
222
222
  end
223
223
 
@@ -252,7 +252,7 @@ module Samagotchi
252
252
  end
253
253
 
254
254
  pending = session.pending_question
255
- return nil unless pending && pending[:kind].to_s == "continue"
255
+ return nil unless pending && pending.kind.to_s == "continue"
256
256
  return nil unless turn_ids?(session_dir)
257
257
 
258
258
  session.last_turn&.enqueued_id
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # whose +to_h+ the row carries as +refs+ on tool_call_started and
11
11
  # tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
12
12
  # as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
13
- # plugins (Web::MessageParts). Display only: the model never sees them.
13
+ # plugins (MessageParts). Display only: the model never sees them.
14
14
  #
15
15
  # A file ref is any file under the session's folder (plan D7): a relative
16
16
  # path is resolved against it, a path outside it is dropped. The folder
@@ -8,6 +8,7 @@ require_relative "atomic_file"
8
8
  require_relative "context_note"
9
9
  require_relative "interrupted_turn"
10
10
  require_relative "last_turn"
11
+ require_relative "pending_question"
11
12
  require_relative "llm_context_override"
12
13
  require_relative "session_setup"
13
14
  require_relative "prompt_note"
@@ -49,7 +50,7 @@ module Samagotchi
49
50
 
50
51
  attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
51
52
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
52
- :pending_question, :used_memory_names
53
+ :used_memory_names
53
54
  # Memories the session was started with (--memory) and memories hidden
54
55
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
55
56
  # Names as given; the engine normalizes them.
@@ -98,6 +99,15 @@ module Samagotchi
98
99
  # A `chi scratch` session: deleted when its REPL ends, and by the next
99
100
  # sweep (or `chi sessions clean`) when the process died first.
100
101
  attr_accessor :scratch
102
+ # The question this session waits on (PendingQuestion), saved as
103
+ # session.json's "pending_question"; nil with none. A Hash (a saved
104
+ # record, string or symbol keys) is read with PendingQuestion.from_file.
105
+ attr_reader :pending_question
106
+
107
+ def pending_question=(value)
108
+ @pending_question = PendingQuestion.from_file(value)
109
+ end
110
+
101
111
  # How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
102
112
  # and the web's notifications (the hub sends it in the summary); nil
103
113
  # before the first turn.
@@ -246,7 +256,7 @@ module Samagotchi
246
256
  @last_prompt = last_prompt
247
257
  @first_preview = first_preview
248
258
  @test_run = !!test_run
249
- @pending_question = pending_question
259
+ self.pending_question = pending_question
250
260
  @used_memory_names = self.class.name_list(used_memory_names)
251
261
  @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
252
262
  @muted_memory_names = self.class.name_list(muted_memory_names)
@@ -289,12 +299,12 @@ module Samagotchi
289
299
  # @return [Hash, nil]
290
300
  def waiting_question(live:)
291
301
  pending = pending_question
292
- return nil unless live && pending.is_a?(Hash) && pending[:id]
302
+ return nil unless live && pending&.id
293
303
 
294
- kind = pending[:kind].to_s
295
- relayed = pending[:relayed_to]
304
+ kind = pending.kind.to_s
305
+ relayed = pending.relayed_to
296
306
  relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
297
- { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
307
+ { id: pending.id, kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
298
308
  end
299
309
 
300
310
  # A question saved by a process that no longer runs this session (the
@@ -306,7 +316,7 @@ module Samagotchi
306
316
  return nil unless pending
307
317
 
308
318
  @pending_question = nil
309
- pending[:id].to_s
319
+ pending.id.to_s
310
320
  end
311
321
 
312
322
  # The stored project root, else (a file from before the field) the
@@ -390,10 +400,10 @@ module Samagotchi
390
400
 
391
401
  # The fields of <id>.json, in the order #save writes them: key => the
392
402
  # value for a file without it (REQUIRED for the ones it must have).
393
- # .from_h and #to_h both go by this table; messages and
394
- # pending_question change key type on the way (symbols in memory,
395
- # strings on disk); last_turn and prompt_notes are value objects in
396
- # memory (LastTurn, PromptNote).
403
+ # .from_h and #to_h both go by this table; messages change key type on
404
+ # the way (symbols in memory, strings on disk); last_turn, prompt_notes
405
+ # and pending_question are value objects in memory (LastTurn,
406
+ # PromptNote, PendingQuestion).
397
407
  FIELDS = {
398
408
  "metadata_version" => 1,
399
409
  "id" => REQUIRED,
@@ -435,8 +445,6 @@ module Samagotchi
435
445
  [key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
436
446
  end
437
447
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
438
- pending = attrs[:pending_question]
439
- attrs[:pending_question] = pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil
440
448
  new(**attrs)
441
449
  end
442
450
 
@@ -557,7 +565,7 @@ module Samagotchi
557
565
  when "messages"
558
566
  (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
559
567
  when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
560
- when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
568
+ when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
561
569
  when "test_run" then !!@test_run
562
570
  when "delegate" then @delegate
563
571
  when "llm_context" then llm_context_file
@@ -592,6 +592,11 @@ module Samagotchi
592
592
  @engine.switch_model!(name, persist_default: persist_default)
593
593
  @default_model = @engine.default_model_name if persist_default
594
594
  persist_session_model
595
+ # The ctx % on the new model's window, before its first turn; saved
596
+ # too, so the session's card (the hub's summary reads analytics.json)
597
+ # stops showing the old model's % before a turn ends.
598
+ @engine.announce_switched_context!
599
+ @engine.metrics.persist(state_dir: @engine.session_state_dir)
595
600
  end
596
601
 
597
602
  def persist_session_model
@@ -244,8 +244,7 @@ module Samagotchi
244
244
  end
245
245
  # And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
246
246
  # XDG_CONFIG_HOME would otherwise be read from the session's directory.
247
- child_env["XDG_STATE_HOME"] = Paths.state_home unless ENV.fetch("XDG_STATE_HOME", "").to_s.strip.empty?
248
- child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
247
+ child_env.merge!(Paths.absolute_xdg)
249
248
  # The spawner's environment must not reach the worker's guardrails (a
250
249
  # parent agent starting or waking a session): Process.spawn unsets a
251
250
  # key mapped to nil. config.yml no longer reads them; this keeps an
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "totals"
4
+
5
+ module Samagotchi
6
+ class SessionMetrics
7
+ # A run of consecutive turns on one model (their records' model_ref, the
8
+ # asked host:model): /stats and the web's ctx/cost tooltip split the
9
+ # session's tokens, cost and speed by these, in order, so A -> B -> A is
10
+ # three. Derived from the turn records as they are counted, never saved
11
+ # on their own. A record with no ref (a turn stopped before it asked a
12
+ # model) joins the segment before it; records with none before the first
13
+ # one that names a model (a session older than model_ref) make one
14
+ # leading segment with no ref.
15
+ #
16
+ # +prefill_tokens+ / +prefill_cost_estimate+: what the segment's first
17
+ # turn prefilled on its model after the switch (the records'
18
+ # switch_prefill_*), 0 / nil for none.
19
+ class ModelSegment < Data.define(:model_ref, :started_at, :ended_at, :turns, :totals, :prefill_tokens,
20
+ :prefill_cost_estimate)
21
+ # The most rows a snapshot sends: older ones fold into one.
22
+ CAP = 6
23
+
24
+ # A segment opened by +record+ (its first turn).
25
+ # @param record [Hash] a turn record (symbol keys)
26
+ # @return [ModelSegment]
27
+ def self.open(record)
28
+ ref = record[:model_ref].to_s
29
+ new(model_ref: ref.empty? ? nil : ref, started_at: record[:started_at], ended_at: nil, turns: 0,
30
+ totals: Totals.zero, prefill_tokens: 0, prefill_cost_estimate: nil).add(record)
31
+ end
32
+
33
+ # The segment a snapshot shows for the running turn when it is on
34
+ # another model than the last segment's: no finished turn yet.
35
+ # @param ref [String]
36
+ # @param started_at [String, nil]
37
+ # @return [ModelSegment]
38
+ def self.empty(ref, started_at)
39
+ new(model_ref: ref, started_at: started_at, ended_at: nil, turns: 0, totals: Totals.zero, prefill_tokens: 0,
40
+ prefill_cost_estimate: nil)
41
+ end
42
+
43
+ # Whether +record+ starts a new segment after this one: it names a
44
+ # model, and another one than this segment's.
45
+ def self.splits?(segment, record)
46
+ ref = record[:model_ref].to_s
47
+ segment.nil? || (!ref.empty? && segment.model_ref != ref)
48
+ end
49
+
50
+ # This segment with one more finished turn record.
51
+ # @return [ModelSegment]
52
+ def add(record)
53
+ tokens = record[:switch_prefill_tokens]
54
+ estimate = record[:switch_prefill_cost_estimate]
55
+ with(ended_at: record[:completed_at] || ended_at, turns: turns + 1, totals: totals.add(record),
56
+ prefill_tokens: prefill_tokens + (tokens.is_a?(Numeric) ? tokens : 0),
57
+ prefill_cost_estimate: estimate.is_a?(Numeric) ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
58
+ end
59
+
60
+ # This segment with the running turn (SessionMetrics::TurnState) in:
61
+ # its finished generations and the prefill it recorded so far; it
62
+ # runs on, so it has no end.
63
+ # @return [ModelSegment]
64
+ def with_running(turn)
65
+ estimate = turn.switch_prefill_cost_estimate
66
+ with(ended_at: nil, turns: turns + 1, totals: totals.with_running(turn),
67
+ prefill_tokens: prefill_tokens + turn.switch_prefill_tokens.to_i,
68
+ prefill_cost_estimate: estimate ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
69
+ end
70
+
71
+ # The row a snapshot carries (JSON-ready). +ended_at+ nil: the segment
72
+ # runs now.
73
+ # @return [Hash]
74
+ def to_h
75
+ tokens = totals.tokens_block
76
+ {
77
+ model_ref: model_ref, started_at: started_at, ended_at: ended_at, turns: turns,
78
+ **tokens.slice(:prompt_sum, :completion_sum, :cached_sum, :cost_sum, :cost_estimate_sum, :avg_decode_tps,
79
+ :last_decode_tps, :tps_source),
80
+ prefill_tokens: prefill_tokens, prefill_cost_estimate: prefill_cost_estimate
81
+ }
82
+ end
83
+
84
+ # The rows of +segments+ a snapshot sends: none for fewer than 2 (one
85
+ # model: the plain totals say it all); over CAP, the oldest fold into
86
+ # one row with no ref that says how many it holds (folded_segments).
87
+ # @param segments [Array<ModelSegment>]
88
+ # @return [Array<Hash>]
89
+ def self.rows(segments)
90
+ return [] if segments.size < 2
91
+ return segments.map(&:to_h) if segments.size <= CAP
92
+
93
+ older = segments.first(segments.size - CAP + 1)
94
+ folded = older.drop(1).reduce(older.first.with(model_ref: nil)) do |sum, segment|
95
+ sum.merge(segment)
96
+ end
97
+ [folded.to_h.merge(folded_segments: older.size), *segments.last(CAP - 1).map(&:to_h)]
98
+ end
99
+
100
+ # This segment and a later one as one row (the folded older ones).
101
+ # @return [ModelSegment]
102
+ def merge(later)
103
+ estimate = [prefill_cost_estimate, later.prefill_cost_estimate].compact
104
+ with(ended_at: later.ended_at, turns: turns + later.turns, totals: totals.merge(later.totals),
105
+ prefill_tokens: prefill_tokens + later.prefill_tokens, prefill_cost_estimate: estimate.empty? ? nil : estimate.sum)
106
+ end
107
+ end
108
+ end
109
+ end