samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -8,6 +8,7 @@ require "time"
8
8
  require_relative "log"
9
9
  require_relative "tools/ask_user_question"
10
10
  require_relative "guardrails/parent_approvals"
11
+ require_relative "guardrails/parent_continue"
11
12
 
12
13
  module Samagotchi
13
14
  # An Engine's question flow: the one open question (ask_user_question, a
@@ -15,6 +16,15 @@ module Samagotchi
15
16
  # by every UI. The turn thread blocks in #open_question; the UIs answer or
16
17
  # cancel from theirs (a REPL answers inline through the sync handler).
17
18
  #
19
+ # A standing question (#post: a worker's step-limit question, kind
20
+ # "continue") blocks nobody: it waits between turns, its answer goes to
21
+ # its on_answer, and its poster withdraws it (#withdraw). It can't be
22
+ # dismissed. One question stays pending at a time: a question opened
23
+ # while one stands supersedes it (withdrawn, reason "superseded"), and
24
+ # once that one closes the standing one is offered again
25
+ # (on_superseded_close); one posted while another is open waits for it
26
+ # the same way.
27
+ #
18
28
  # What it needs from the Engine comes through lookups, read at call time.
19
29
  class QuestionDesk
20
30
  # Raised by #answer when the question it targets is no longer open
@@ -24,18 +34,28 @@ module Samagotchi
24
34
 
25
35
  # Raised by #answer when a parent agent's answer (chi answer's client
26
36
  # id) allows an approval further than this worker's
27
- # guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
28
- # The question stays open; transports map it to 403.
37
+ # guardrails.parent_approvals lets it (Guardrails::ParentApprovals), or
38
+ # continues a turn turn.parent_continue keeps it from
39
+ # (Guardrails::ParentContinue). The question stays open; transports map
40
+ # it to 403.
29
41
  class Refused < StandardError
30
- # @return [Symbol] :off or :once_only
42
+ # @return [Symbol] :off, :once_only, :protected or :stop_only
31
43
  attr_reader :reason
32
44
 
33
45
  def initialize(reason)
34
46
  @reason = reason
35
- super("a parent may not allow this approval (#{reason})")
47
+ super(reason == :stop_only ? "a parent may not continue this turn" : "a parent may not allow this approval (#{reason})")
36
48
  end
37
49
  end
38
50
 
51
+ # Raised by #cancel for a standing question (#post): its poster closes
52
+ # it, an answer settles it. Transports map it to 409 not_dismissable.
53
+ class NotDismissable < StandardError; end
54
+
55
+ # A step-limit question's options (kind "continue").
56
+ CONTINUE_KIND = "continue"
57
+ CONTINUE_OPTION = "Continue"
58
+
39
59
  # Seconds between two looks of an open question's watch (#open_question).
40
60
  WATCH_INTERVAL = 0.5
41
61
 
@@ -62,6 +82,11 @@ module Samagotchi
62
82
  @pending = nil
63
83
  @answer = nil
64
84
  @sync_handler = nil
85
+ # The standing question while it is @pending: {id:, fields:,
86
+ # on_answer:, on_superseded_close:}; @shelved, one superseded by (or
87
+ # posted during) another question, offered again when that closes.
88
+ @standing = nil
89
+ @shelved = nil
65
90
  end
66
91
 
67
92
  # @return [Hash, nil] current pending question (thread-safe copy)
@@ -99,23 +124,68 @@ module Samagotchi
99
124
  # @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
100
125
  # or {error:, …}; a String when a sync handler returned text itself
101
126
  def open_question(fields, watch: nil)
102
- id = SecureRandom.uuid
103
- pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
127
+ supersede_standing
128
+ ask(fields, watch)
129
+ ensure
130
+ offer_shelved
131
+ end
104
132
 
105
- @lock.synchronize do
106
- @pending = pending
107
- @answer = nil
133
+ # Publish a standing question: pending (saved, announced with
134
+ # standing: true) without blocking anyone. An answer to it is checked
135
+ # as any other and handed to +on_answer+ (answer, client_id:) on the
136
+ # answering thread, after it is cleared and :question_answered is out.
137
+ # Posted while another question is open, it waits for that one to
138
+ # close (as a superseded one does). A standing one already up is
139
+ # replaced (withdrawn, reason "replaced").
140
+ # @param fields [Hash] as for #open_question
141
+ # @param on_answer [#call] (answer, client_id:)
142
+ # @param on_superseded_close [#call, nil] called when a question that
143
+ # superseded it closes (its poster posts it again if it still
144
+ # stands); nil: posted again as it was
145
+ # @return [Hash, nil] the pending question; nil when it waits for another
146
+ def post(fields, on_answer:, on_superseded_close: nil)
147
+ standing = { fields: fields, on_answer: on_answer, on_superseded_close: on_superseded_close }
148
+ shelved = @lock.synchronize do
149
+ next false unless @pending && !@standing
150
+
151
+ @shelved = standing
152
+ true
108
153
  end
109
- # Persist to session file for WEB stub + resume (generic for all UIs)
110
- if session
111
- session.pending_question = pending.dup
112
- begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
154
+ return nil if shelved
155
+
156
+ withdraw("replaced")
157
+ publish(fields, standing: standing)
158
+ end
159
+
160
+ # Withdraw the standing question (its poster's close: the offer went,
161
+ # was answered by a command, the worker leaves), announced as
162
+ # :question_cancelled with +reason+. A shelved one just goes.
163
+ # @param id [String, nil] only this one
164
+ # @return [Boolean] whether one was pending
165
+ def withdraw(reason, id: nil)
166
+ withdrawn = @lock.synchronize do
167
+ @shelved = nil if id.nil?
168
+ standing = @standing
169
+ next nil unless standing && @pending && @pending[:id] == standing[:id]
170
+ next nil if id && standing[:id].to_s != id.to_s
171
+
172
+ @standing = nil
173
+ @pending = nil
174
+ standing[:id]
113
175
  end
114
- # Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
115
- # stash this event (e.g. TerminalUI handle_question_event) will
116
- # discard it as stale if the synchronous handler below already answers
117
- # and clears pending — see drain_pending_question? staleness check.
118
- emit({ type: :question_requested, pending_question: pending })
176
+ return false unless withdrawn
177
+
178
+ clear_saved_question
179
+ emit({ type: :question_cancelled, id: withdrawn, reason: reason.to_s })
180
+ true
181
+ end
182
+
183
+ # @return [Boolean] the pending question is a standing one
184
+ def standing? = @lock.synchronize { !@standing.nil? }
185
+
186
+ private def ask(fields, watch)
187
+ pending = publish(fields)
188
+ id = pending[:id]
119
189
 
120
190
  # If a synchronous UI handler is registered (TUI), invoke it inline on the
121
191
  # SAME thread that called request_question (TerminalUI's REPL thread is the
@@ -202,7 +272,9 @@ module Samagotchi
202
272
  parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
203
273
  # Read before the lock (config may touch the disk); this worker's own.
204
274
  parent_setting = Guardrails::ParentApprovals.setting if parent_agent
205
- @lock.synchronize do
275
+ parent_continue = Guardrails::ParentContinue.allowed? if parent_agent
276
+ standing = nil
277
+ answer = @lock.synchronize do
206
278
  pending = @pending
207
279
  raise NotPending, "no pending question" unless pending
208
280
  raise NotPending, "id mismatch" unless pending[:id].to_s == id.to_s
@@ -212,37 +284,25 @@ module Samagotchi
212
284
  raise NotPending, "question already answered" if @answer
213
285
  raise NotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
214
286
 
215
- opts = Array(pending[:options])
216
- # Validate selected subset of options (value == label in v1)
217
- invalid = sel.reject { |v| opts.include?(v) }
218
- unless invalid.empty?
219
- raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
287
+ answer = validated_answer(pending, sel, fm, parent_agent: parent_agent, parent_setting: parent_setting)
288
+ if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
289
+ raise Refused, reason
220
290
  end
221
- if !pending[:multi_select] && sel.size > 1
222
- raise ArgumentError, "single-select question: got #{sel.size} selections"
291
+ if @standing
292
+ # No waiter: cleared here, so a second answer finds none.
293
+ standing = @standing
294
+ @standing = nil
295
+ @pending = nil
296
+ else
297
+ @answer = answer
298
+ @cv.broadcast
223
299
  end
224
- if pending[:multi_select] == false && sel.empty? && fm.nil?
225
- raise ArgumentError, "selection required"
226
- end
227
- indices = sel.map { |v| opts.index(v) }
228
- # Checked against the question pending now, under the lock: the one
229
- # whose answer settles the approval (Approval.settle, by index).
230
- if parent_setting
231
- reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
232
- raise Refused, reason if reason
233
- end
234
- # Persist pending cleared elsewhere; just set answer
235
- answer = { id: id.to_s, selected: sel, freeform: fm }
236
- # Derive indices for convenience
237
- answer[:selected_indices] = indices.compact
238
- answer[:by] = "parent_agent" if parent_agent
239
- @answer = answer
240
- @cv.broadcast
241
300
  answer
242
- end.tap do
243
- # A human answered: the session is back in the lists (ArchiveStore).
244
- @user_input.call(session&.id) unless parent_agent
245
301
  end
302
+ settle_standing(standing, answer, client_id) if standing
303
+ # A human answered: the session is back in the lists (ArchiveStore).
304
+ @user_input.call(session&.id) unless parent_agent
305
+ answer
246
306
  end
247
307
 
248
308
  # Mark the pending question as relayed to a parent session's user, or
@@ -287,12 +347,16 @@ module Samagotchi
287
347
  # names the one it showed)
288
348
  # @return [Boolean] whether it was cancelled (true with none pending and
289
349
  # no id, as before)
350
+ # @raise [NotDismissable] +id+ names a standing question (#post)
290
351
  def cancel(reason = "user", id: nil)
291
352
  cancelled_id = @lock.synchronize do
292
353
  pending = @pending
293
354
  next unless pending
294
355
  next if id && pending[:id].to_s != id.to_s
295
356
  next if @answer || pending[:status].to_s != "pending"
357
+ # Its poster closes it; nothing waits on a cancel.
358
+ raise NotDismissable, "answer #{Array(pending[:options]).join(' or ')}" if @standing && id
359
+ next if @standing
296
360
 
297
361
  pending[:status] = "cancelled"
298
362
  @cv.broadcast
@@ -330,6 +394,101 @@ module Samagotchi
330
394
 
331
395
  private
332
396
 
397
+ # Make +fields+ the pending question: set under the lock, saved to the
398
+ # session file (the web's stub, a resume, the lists) and announced as
399
+ # :question_requested to every UI. Observers that stash the event (e.g.
400
+ # TerminalUI handle_question_event) discard it as stale if a sync
401
+ # handler answers and clears it first (drain_pending_question?).
402
+ # @return [Hash] the pending question
403
+ # @param standing [Hash, nil] a standing question's record (#post)
404
+ def publish(fields, standing: nil)
405
+ pending = { id: SecureRandom.uuid, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
406
+ @lock.synchronize do
407
+ @pending = pending
408
+ @answer = nil
409
+ @standing = standing&.merge(id: pending[:id])
410
+ end
411
+ if session
412
+ session.pending_question = pending.dup
413
+ begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
414
+ end
415
+ event = { type: :question_requested, pending_question: pending }
416
+ event[:standing] = true if standing
417
+ emit(event)
418
+ pending
419
+ end
420
+
421
+ # A question is about to open: a standing one gives way (withdrawn,
422
+ # reason "superseded") and is shelved until the new one closes.
423
+ def supersede_standing
424
+ standing = @lock.synchronize do
425
+ next nil unless @standing
426
+
427
+ @shelved = @standing
428
+ @standing = nil
429
+ @pending = nil
430
+ @shelved[:id]
431
+ end
432
+ emit({ type: :question_cancelled, id: standing, reason: "superseded" }) if standing
433
+ end
434
+
435
+ # The question that superseded a standing one closed: offer it again.
436
+ def offer_shelved
437
+ shelved = @lock.synchronize do
438
+ next nil if @pending || @shelved.nil?
439
+
440
+ @shelved.tap { @shelved = nil }
441
+ end
442
+ return unless shelved
443
+
444
+ if shelved[:on_superseded_close]
445
+ shelved[:on_superseded_close].call
446
+ else
447
+ post(shelved[:fields], on_answer: shelved[:on_answer])
448
+ end
449
+ rescue StandardError => e
450
+ Log.warn(:turn, "standing_question_repost_failed", error: e.class.name, message: e.message)
451
+ end
452
+
453
+ # A standing question's answer: cleared from the file, announced, then
454
+ # handed to its poster with who answered.
455
+ def settle_standing(standing, answer, client_id)
456
+ clear_saved_question
457
+ emit({ type: :question_answered, id: answer[:id], answer: answer })
458
+ standing[:on_answer].call(answer.except(:by), client_id: client_id)
459
+ rescue StandardError => e
460
+ Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
461
+ end
462
+
463
+ # An answer to +pending+, checked: the selections are its options (value
464
+ # == label in v1), one for a single-select, one or a text when required,
465
+ # and a parent agent's within guardrails.parent_approvals. Under the
466
+ # question lock: checked against the question pending now.
467
+ # @return [Hash] {id:, selected:, freeform:, selected_indices:, by:}
468
+ # @raise [ArgumentError, Refused]
469
+ def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
470
+ opts = Array(pending[:options])
471
+ invalid = sel.reject { |v| opts.include?(v) }
472
+ raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})" unless invalid.empty?
473
+ raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
474
+ raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
475
+ # A step-limit question: Continue resumes the turn as it was; a text
476
+ # goes with Stop (steering a continue is not a thing).
477
+ if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
478
+ raise ArgumentError, "Continue takes no text (a text goes with Stop)"
479
+ end
480
+
481
+ indices = sel.map { |v| opts.index(v) }
482
+ # The one whose answer settles the approval (Approval.settle, by index).
483
+ if parent_setting
484
+ reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
485
+ raise Refused, reason if reason
486
+ end
487
+ answer = { id: pending[:id].to_s, selected: sel, freeform: fm, selected_indices: indices.compact }
488
+ answer[:by] = "parent_agent" if parent_agent
489
+ answer
490
+ end
491
+
333
492
  # Wait until the question is answered, cancelled or closed by +watch+.
334
493
  # @return [String, nil] the watch's close reason
335
494
  def wait_for_answer(watch)
@@ -26,10 +26,14 @@ module Samagotchi
26
26
  # @!attribute question [Hash, nil] the pending question
27
27
  # (:waiting_for_answer)
28
28
  # @!attribute outcome [String, nil] how the turn ended when it left no
29
- # reply (:no_reply): "failed", "canceled" or "completed" (empty), from
30
- # the session's last_turn; nil when unknown. With "failed", text is
31
- # the failure's summary when the turn note has one.
32
- Result = Struct.new(:status, :text, :file, :question, :outcome, keyword_init: true)
29
+ # reply (:no_reply): "failed", "canceled", "completed" (empty),
30
+ # "exhausted" (it ran out of iterations, with nobody asked whether to
31
+ # continue) or "not_continued" (a Stop answered the step-limit
32
+ # question; no turn ran), from the session's last_turn; nil when unknown. With
33
+ # "failed", text is the failure's summary when the turn note has one.
34
+ # @!attribute limit [Integer, nil] the iteration limit an "exhausted"
35
+ # turn ran out at
36
+ Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, keyword_init: true)
33
37
 
34
38
  module_function
35
39
 
@@ -144,6 +148,8 @@ module Samagotchi
144
148
  last = session.last_turn.is_a?(Hash) ? session.last_turn : {}
145
149
  fresh = baseline.nil? || !baseline.key?(:last_turn) || (last["ended_at"] && last["ended_at"] != baseline[:last_turn])
146
150
  outcome = fresh ? last["outcome"] : nil
151
+ return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
152
+
147
153
  text = outcome == "failed" ? failure_summary(session.messages) : nil
148
154
  Result.new(status: :no_reply, outcome: outcome, text: text)
149
155
  end
@@ -7,6 +7,7 @@ require_relative "context_window"
7
7
  require_relative "session"
8
8
  require_relative "log_path"
9
9
  require_relative "model_profile"
10
+ require_relative "model_overlay"
10
11
  require_relative "thinking"
11
12
  require_relative "served_model"
12
13
  require_relative "host_registry"
@@ -39,7 +40,8 @@ module Samagotchi
39
40
  end
40
41
 
41
42
  def fields(env: ENV)
42
- model = model_name
43
+ default = model_name
44
+ model = session_model(env) || default
43
45
  [
44
46
  ["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
45
47
  ["source", "#{SOURCE_DIR} (#{install_kind})"],
@@ -49,7 +51,8 @@ module Samagotchi
49
51
  ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
50
52
  ["sessions", Session.default_state_dir(env: env)],
51
53
  ["log", log_path(env)],
52
- ["model", model || "(not configured)"],
54
+ ["model", model ? model_label(model, default, env) : "(not configured)"],
55
+ ["model key", model ? model_key_for(model, env) : "-"],
53
56
  ["host", model ? host_for(model, env) : "-"],
54
57
  ["api key", model ? api_key_for(model, env) : "-"],
55
58
  ["loop", model ? loop_for(model, env) : "-"],
@@ -125,6 +128,46 @@ module Samagotchi
125
128
  nil
126
129
  end
127
130
 
131
+ SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
132
+ PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
133
+
134
+ # The model of the session this chi self runs in (its execute exports
135
+ # SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
136
+ # outside a session's commands.
137
+ def session_model(env)
138
+ name = env[SESSION_MODEL_ENV].to_s.strip
139
+ name.empty? ? nil : name
140
+ end
141
+
142
+ # "splash:x (this session abcd1234; default main:y)", "main:y (this
143
+ # session abcd1234, the default)", or outside a session "main:y (default)".
144
+ def model_label(model, default, env)
145
+ return "#{model} (default)" unless session_model(env)
146
+
147
+ id = env[PARENT_SESSION_ENV].to_s
148
+ session = id.empty? || id == "chi" ? "this session" : "this session #{id[0, 8]}"
149
+ return "#{model} (#{session}, the default)" if default && same_model?(model, default, env)
150
+
151
+ "#{model} (#{session}; default #{default || "not configured"})"
152
+ end
153
+
154
+ def same_model?(one, other, env)
155
+ return true if one == other
156
+
157
+ ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
158
+ rescue StandardError
159
+ false
160
+ end
161
+
162
+ # The memory overlay key (ModelOverlay.key_for the bare id, as the
163
+ # engine keys memory_write current_model_only): which `<name>.<key>.md`
164
+ # overlays are this model's.
165
+ def model_key_for(model, env)
166
+ ModelOverlay.key_for(HostRegistry.new(env: env).bare_name(model)) || "-"
167
+ rescue StandardError
168
+ ModelOverlay.key_for(model) || "-"
169
+ end
170
+
128
171
  def model_name
129
172
  ModelProfile.required_model_name
130
173
  rescue ArgumentError
@@ -147,6 +190,8 @@ module Samagotchi
147
190
  entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
148
191
  end
149
192
 
193
+ NO_PROPS = "reported per turn (the server has no /props)"
194
+
150
195
  # chi self's one server call: a single-model llama.cpp answers any name
151
196
  # with the model it loaded, and its /props names it (model_alias). One
152
197
  # GET with the probe's short timeouts; a remote host isn't asked.
@@ -157,10 +202,11 @@ module Samagotchi
157
202
  bare = target.bare_model
158
203
  return "-" unless entry
159
204
  return "reported per turn (remote host)" if entry.remote?
205
+ return NO_PROPS if entry.chat?
160
206
 
161
207
  client = registry.client_for(entry)
162
208
  props = client.server_props(model: bare)
163
- return "reported per turn (the server has no /props)" if props.nil?
209
+ return NO_PROPS if props.nil? || props.status == :http_error
164
210
  return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
165
211
 
166
212
  served = ServedModel.from_props(props)
@@ -37,6 +37,8 @@ module Samagotchi
37
37
  chi send --wait [--timeout S] [--format json] ID
38
38
  Sends a message to each session, as if typed in it: a turn starts,
39
39
  or a running one picks it up. A stopped session's worker starts.
40
+ A session command (/model x, !cmd) runs as the command; --wait
41
+ then has no reply to wait for (exit 0, status command).
40
42
  -m TEXT the message; stdin, when piped too, goes above it as a
41
43
  quote (context); without -m, stdin is the message
42
44
  --image PATH
@@ -64,7 +66,8 @@ module Samagotchi
64
66
  (--wait) one JSON object on stdout, whatever the end:
65
67
  status answered (text), question (question,
66
68
  answer_with), running, or failed, canceled,
67
- no_answer, error, worker_gone, stopped (detail)
69
+ limit, no_answer, error, worker_gone, stopped,
70
+ command (detail)
68
71
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
69
72
  question waits, 4 still running (--timeout), 130 Ctrl-C.
70
73
  Only sessions on this machine. Answers show in the attached TUI
@@ -246,23 +249,27 @@ module Samagotchi
246
249
  error_line("chi send: refused: #{refusal}")
247
250
  return 1
248
251
  end
252
+ # With images the web's way: idle (the message names it in the
253
+ # lists), the images copied in, then the message as a turn. So too a
254
+ # line that may be a session command (/model x): its worker's answer
255
+ # says whether it ran as one.
256
+ through_worker = images || command_like?(prompt)
249
257
  begin
250
- # With images the web's way: idle (the message names it in the
251
- # lists), the images copied in, then the message as a turn.
252
- start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
258
+ start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
253
259
  session = SessionManager.spawn_session(**start, working_directory: options[:dir],
254
260
  model_name: options[:model], state_dir: @state_dir)
255
261
  rescue StandardError => e
256
262
  error_line("chi send: could not start a session: #{e.message}")
257
263
  return 1
258
264
  end
259
- if images
265
+ if through_worker
260
266
  return 1 unless deliver_new(session, prompt)
261
267
  else
262
268
  @info.puts("#{session.id} started")
263
269
  end
264
270
  @session_id = session.id
265
271
  return 0 unless options[:wait]
272
+ return report_command(session.id) if @sent_command
266
273
 
267
274
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
268
275
  timeout: options[:timeout])
@@ -297,6 +304,7 @@ module Samagotchi
297
304
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
298
305
  baseline = baseline_of(session)
299
306
  return 1 unless deliver(id, prompt)
307
+ return report_command(id) if @sent_command
300
308
 
301
309
  wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
302
310
  end
@@ -352,10 +360,36 @@ module Samagotchi
352
360
  return false
353
361
  end
354
362
 
355
- @info.puts("#{session.id} started#{with_images}")
363
+ @sent_command = command_ack?(result[:ack])
364
+ @info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
356
365
  true
357
366
  end
358
367
 
368
+ SENT_COMMAND = "sent as a session command"
369
+
370
+ # Whether the worker took the message as a session command (a POST
371
+ # /turn whose line is one answers with its command_id).
372
+ def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
373
+
374
+ # Whether +text+ may be a session command, which only its worker knows
375
+ # (a bundle's too).
376
+ def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
377
+
378
+ # --wait after a message that ran as a session command: no reply comes
379
+ # for it. Its output shows where the session is open.
380
+ # @return [Integer] 0
381
+ def report_command(id)
382
+ detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
383
+ if @json
384
+ @stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
385
+ @stdout.flush
386
+ @reported = true
387
+ else
388
+ @stderr.puts("#{id[0, 8]} #{detail}")
389
+ end
390
+ CLI::Exit::OK
391
+ end
392
+
359
393
  # @return [Boolean] whether the message was queued
360
394
  def deliver(id, prompt)
361
395
  short = id[0, 8]
@@ -373,6 +407,12 @@ module Samagotchi
373
407
  return false
374
408
  end
375
409
 
410
+ @sent_command = command_ack?(result[:ack])
411
+ if @sent_command
412
+ @info.puts("#{short} #{SENT_COMMAND}")
413
+ return true
414
+ end
415
+
376
416
  # A busy worker runs a message with images as its own next turn
377
417
  # rather than merging it into the running one.
378
418
  note = if owner.nil? then " (started its worker)"
@@ -100,7 +100,8 @@ module Samagotchi
100
100
  end
101
101
 
102
102
  # The question this session waits on, as the lists show it: {id:, kind:}
103
- # with kind "question" (the model's), "approval" or "hook"; nil with
103
+ # with kind "question" (the model's), "approval", "hook" or "continue"
104
+ # (the step-limit question, asked between turns); nil with
104
105
  # none. Only while a worker runs it (+live+): a question saved by a
105
106
  # worker that died waits for no one (the next worker drops it). The
106
107
  # web's summaries and `chi sessions list` both ask this.
@@ -113,7 +114,7 @@ module Samagotchi
113
114
  kind = pending[:kind].to_s
114
115
  relayed = pending[:relayed_to]
115
116
  relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
116
- { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
117
+ { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
117
118
  end
118
119
 
119
120
  # The stored project root, else (a file from before the field) the
@@ -158,11 +158,11 @@ module Samagotchi
158
158
  when :abort
159
159
  @turn_flow.abort_continue!
160
160
  save_session
161
- reply("interrupted turn cancelled; enter your next prompt", changed: [:messages])
161
+ reply("turn not continued; its work so far stays (!rollback erases it)", changed: [:messages])
162
162
  when :abort_with_reason
163
163
  @turn_flow.abort_continue!(reason: reason)
164
164
  save_session
165
- reply("interrupted turn cancelled; noted your explanation", changed: [:messages])
165
+ reply("turn not continued; noted your reason", changed: [:messages])
166
166
  else
167
167
  reply("answer yes, no, or no, <reason>", status: :error)
168
168
  end
@@ -252,6 +252,7 @@ module Samagotchi
252
252
  ("tool #{rule.tools.join(",")}" if rule.tools),
253
253
  ("command /#{shorten_pattern(rule.command.source)}/" if rule.command),
254
254
  ("path #{rule.path}" if rule.path),
255
+ ("git #{rule.git}" if rule.git),
255
256
  ("models #{rule.models.join(",")}" if rule.models)
256
257
  ].compact.join(", ")
257
258
  off = "disabled (guardrails.disable) — " if rules.disabled?(rule)
@@ -364,7 +364,10 @@ module Samagotchi
364
364
  cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
365
365
  end
366
366
 
367
- private_class_method def self.summary_text(session)
367
+ # A session as one line of text for the lists: its last prompt, else the
368
+ # first message it was started with (a session command like `/model x`,
369
+ # or a first turn not saved yet).
370
+ def self.summary_text(session)
368
371
  one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
369
372
  end
370
373