samagotchi 0.17.0 → 0.18.1

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -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
@@ -66,7 +66,8 @@ module Samagotchi
66
66
  (--wait) one JSON object on stdout, whatever the end:
67
67
  status answered (text), question (question,
68
68
  answer_with), running, or failed, canceled,
69
- no_answer, error, worker_gone, stopped, command (detail)
69
+ limit, no_answer, error, worker_gone, stopped,
70
+ command (detail)
70
71
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
71
72
  question waits, 4 still running (--timeout), 130 Ctrl-C.
72
73
  Only sessions on this machine. Answers show in the attached TUI
@@ -17,16 +17,26 @@ module Samagotchi
17
17
  name.is_a?(String) && !name.strip.empty? ? name : nil
18
18
  end
19
19
 
20
+ # The separators a provider uses when it decorates a model name: a tag
21
+ # (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
22
+ # owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
23
+ # after the name is a different model (`gpt-4` is not `gpt-4o`).
24
+ EXTENSION_SEPARATORS = [":", "-", "@", "/"].freeze
25
+
20
26
  # Whether +served+ is another model than +asked+. Names that are equal
21
- # (any case) or where one extends the other (a provider dropping
22
- # `:free`, or adding a date) are the same model; unknown names never
23
- # differ.
27
+ # (any case), or where one extends the other at a tag/date/owner
28
+ # separator (a provider dropping `:free`, or adding a date), are the
29
+ # same model; unknown names never differ.
24
30
  def differs?(asked, served)
25
31
  asked = asked.to_s.strip.downcase
26
32
  served = served.to_s.strip.downcase
27
33
  return false if asked.empty? || served.empty?
34
+ return false if asked == served
35
+
36
+ shorter, longer = asked.length <= served.length ? [asked, served] : [served, asked]
37
+ return true unless longer.start_with?(shorter)
28
38
 
29
- !(asked.start_with?(served) || served.start_with?(asked))
39
+ !longer[shorter.length..].start_with?(*EXTENSION_SEPARATORS)
30
40
  end
31
41
  end
32
42
  end
@@ -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
 
@@ -191,11 +191,12 @@ module Samagotchi
191
191
  order ||= "desc"
192
192
  sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
193
193
  include_archived: include_archived)
194
- # The saved recap's first sentence, else the last prompt, cut as before.
194
+ # The saved recap's first sentence, else the last prompt (or the first
195
+ # message, before any turn), cut as before.
195
196
  state_dir = Samagotchi::Session.default_state_dir
196
197
  list_text = lambda do |s|
197
198
  recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
198
- (recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
199
+ (recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
199
200
  end
200
201
  # A delegated session points at its parent: ↳ <parent's short id>.
201
202
  row = lambda do |s|
@@ -381,7 +381,7 @@ module Samagotchi
381
381
  root = MemoryPaths.project_root(cwd)
382
382
  lines = ["Current working directory:", cwd]
383
383
  unless root == cwd
384
- lines << "Project root (project memories are shared by all worktrees and subdirectories of this repository):"
384
+ lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
385
385
  lines << root
386
386
  end
387
387
  home = Dir.home
@@ -237,6 +237,8 @@ module Samagotchi
237
237
  end_turn
238
238
  own_turn_ended(event)
239
239
  when :prompt_restored then restore_prompt(event)
240
+ # The after_turn hooks are done (their notices came before it).
241
+ when :answer_display then @display_pending = false
240
242
  when :context_status, :used_memories_updated then @status.take_event(event)
241
243
  # Another client's anytime command: its line now, before the cards
242
244
  # it shows (its command_ran comes when it's done).
@@ -342,6 +344,9 @@ module Samagotchi
342
344
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
343
345
  persist_recent_history(text) if PromptHistory.shell_line?(text)
344
346
  send_command(text)
347
+ elsif (hint = command_registry.unknown_command_hint(text))
348
+ # A typo of a command (/modle) is not a prompt: say so, send nothing.
349
+ @screen.commit(hint)
345
350
  else
346
351
  send_prompt(text)
347
352
  end
@@ -522,6 +527,9 @@ module Samagotchi
522
527
  # UI's answer shows as its command line (web> /continue yes) instead.
523
528
  def continue_resolved(event)
524
529
  @continue_offer = nil
530
+ # Asked as the step-limit question: its line says what became of it.
531
+ asked = @offer_asked
532
+ @offer_asked = false
525
533
  sync_continue_slot
526
534
  who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
527
535
  outcome = if event[:decision] == "dropped"
@@ -530,14 +538,14 @@ module Samagotchi
530
538
  @continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
531
539
  end
532
540
  @continue_answer = nil
533
- @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
541
+ @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
534
542
  sync_prompt
535
543
  end
536
544
 
537
545
  # The notes slot shows the offer's choices while it waits (a question
538
546
  # has the slot while it is open).
539
547
  def sync_continue_slot
540
- return if @question
548
+ return if @question || @offer_asked
541
549
 
542
550
  if @continue_offer
543
551
  @screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
@@ -574,9 +582,10 @@ module Samagotchi
574
582
  @screen.commit("could not send the prompt (#{worker_down(e)})")
575
583
  end
576
584
 
577
- # Whether the input's end must wait: the -p prompt isn't sent yet, or
578
- # a prompt this run sent hasn't had its turn.
579
- def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty?
585
+ # Whether the input's end must wait: the -p prompt isn't sent yet, a
586
+ # prompt this run sent hasn't had its turn, or its turn's after_turn
587
+ # hooks still run (their notices, source-links' sources:, come next).
588
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
580
589
 
581
590
  # A turn ended: this run's prompt (and ours merged into it) had its turn.
582
591
  def own_turn_ended(event)
@@ -586,6 +595,8 @@ module Samagotchi
586
595
  ours = @open_ids.include?(id) || !@merged_ids.empty?
587
596
  @own_failed = true if ours && event[:type] == :turn_failed
588
597
  @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
598
+ # The worker sends answer_display once its after_turn hooks ran.
599
+ @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
589
600
  @open_ids.delete(id)
590
601
  @open_ids.subtract(@merged_ids)
591
602
  @merged_ids.clear
@@ -692,6 +703,9 @@ module Samagotchi
692
703
  @set_aside = @reader&.typed_text unless @question
693
704
  @question = QuestionPrompt.new(pending)
694
705
  @question_pending = pending
706
+ # The step-limit question stands for the continue offer's own slot
707
+ # until the offer is resolved.
708
+ @offer_asked = true if @question.continue?
695
709
  # An edit's diff goes above, into the scrollback, once per question
696
710
  # (a join gets it from the snapshot and may get the event too).
697
711
  unless @previewed_id == @question.id
@@ -705,7 +719,9 @@ module Samagotchi
705
719
  end
706
720
 
707
721
  def answer_question(text)
708
- return dismiss_question if text.empty?
722
+ # Enter alone at the step-limit question continues (it can't be
723
+ # dismissed), as at the continue prompt.
724
+ return dismiss_question if text.empty? && !@question.continue?
709
725
 
710
726
  answer = @question.parse(text)
711
727
  @screen.commit(answer.note) if answer.note
@@ -893,6 +909,8 @@ module Samagotchi
893
909
  sync_continue_slot
894
910
  end
895
911
  render_current_turn(snapshot[:current_turn])
912
+ # A question between turns (the step-limit one) belongs to no turn.
913
+ ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
896
914
  render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
897
915
  Array(snapshot[:queued]).each do |entry|
898
916
  next if own?(entry[:client_id])
@@ -951,8 +969,15 @@ module Samagotchi
951
969
  return if text.empty?
952
970
 
953
971
  @screen.commit("#{paint(PROMPT, 92)}#{text}")
954
- # As if typed: a session command runs as the command.
955
- command_registry.command?(text) ? send_command(text) : send_prompt(text)
972
+ # As if typed: a session command runs as the command; a typo of one
973
+ # gets the hint, not a turn.
974
+ if command_registry.command?(text)
975
+ send_command(text)
976
+ elsif (hint = command_registry.unknown_command_hint(text))
977
+ @screen.commit(hint)
978
+ else
979
+ send_prompt(text)
980
+ end
956
981
  end
957
982
 
958
983
  def render_join_header(messages)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatting"
4
+ require_relative "event_renderer"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # The turn sink of `chi -p … --non-interactive` (Engine#run_turn's
9
+ # on_event): the few lines a reader wants while the turn runs, on
10
+ # stderr. stdout is the answer's alone, printed after the turn: a
11
+ # script or a parent agent reads it.
12
+ #
13
+ # It prints the retry lines the REPL shows: the loop asking again after
14
+ # an empty answer (:empty_answer_retry), and a generation retried after
15
+ # a failed request (:generation_retrying). And the hooks' notices
16
+ # (:hook_notice): one during the turn as it comes, one the after_turn
17
+ # hooks give (after :turn_completed; source-links' `sources:`) kept for
18
+ # #flush, which the caller runs after it has printed the answer.
19
+ class OneShotSink
20
+ include Formatting
21
+
22
+ # @param err [IO, nil] nil writes to $stderr as it is at each write
23
+ def initialize(err: nil)
24
+ @err = err
25
+ @turn_over = false
26
+ @held = []
27
+ end
28
+
29
+ def call(event)
30
+ case event[:type]
31
+ when :empty_answer_retry then line(format_empty_retry_line(event))
32
+ when :generation_retrying then line(format_generation_retry_line(event))
33
+ when :turn_completed, :turn_canceled then @turn_over = true
34
+ when :hook_notice
35
+ text = EventRenderer.hook_notice_line(event)
36
+ @turn_over ? @held << text : line(text)
37
+ end
38
+ end
39
+
40
+ # Print the notices that came after the turn's end.
41
+ def flush
42
+ line(@held.shift) until @held.empty?
43
+ end
44
+
45
+ private
46
+
47
+ def line(text)
48
+ err.puts(text)
49
+ err.flush
50
+ end
51
+
52
+ def err = @err || $stderr
53
+
54
+ # Colour when stderr, where the lines go, is a colour terminal.
55
+ def color_output?
56
+ return false unless err.tty?
57
+ return false if ENV.key?("NO_COLOR")
58
+
59
+ ENV.fetch("TERM", "") != "dumb"
60
+ end
61
+ end
62
+ end
63
+ end