samagotchi 0.30.0 → 0.32.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +7 -1
  7. data/docs/context.md +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/plugins.md +77 -1
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +9 -2
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  19. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  20. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +8 -1
  28. data/lib/samagotchi/child_reports.rb +212 -0
  29. data/lib/samagotchi/client.rb +35 -4
  30. data/lib/samagotchi/config.rb +11 -0
  31. data/lib/samagotchi/context_absorber.rb +179 -0
  32. data/lib/samagotchi/context_command.rb +473 -0
  33. data/lib/samagotchi/context_fetch.rb +226 -0
  34. data/lib/samagotchi/context_note.rb +11 -5
  35. data/lib/samagotchi/context_poller.rb +100 -0
  36. data/lib/samagotchi/context_providers.rb +159 -0
  37. data/lib/samagotchi/context_sources.rb +452 -0
  38. data/lib/samagotchi/engine.rb +12 -5
  39. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  41. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  42. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  43. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  44. data/lib/samagotchi/idle_recap.rb +4 -0
  45. data/lib/samagotchi/kernel_loop.rb +44 -5
  46. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  47. data/lib/samagotchi/llm/errors.rb +9 -0
  48. data/lib/samagotchi/log_line.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +1 -1
  50. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  51. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  52. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  53. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  54. data/lib/samagotchi/plugin/context.rb +22 -0
  55. data/lib/samagotchi/prompt_warmup.rb +30 -5
  56. data/lib/samagotchi/question_desk.rb +0 -3
  57. data/lib/samagotchi/reply_wait.rb +39 -8
  58. data/lib/samagotchi/session.rb +30 -7
  59. data/lib/samagotchi/session_commands.rb +18 -1
  60. data/lib/samagotchi/session_inbox.rb +34 -1
  61. data/lib/samagotchi/session_manager.rb +37 -5
  62. data/lib/samagotchi/session_retention.rb +3 -0
  63. data/lib/samagotchi/steer.rb +48 -7
  64. data/lib/samagotchi/system_prompt.rb +1 -1
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
  66. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  67. data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
  68. data/lib/samagotchi/terminal_ui.rb +9 -3
  69. data/lib/samagotchi/tool_activity.rb +8 -0
  70. data/lib/samagotchi/tool_call_parser.rb +40 -0
  71. data/lib/samagotchi/tool_declarations.rb +28 -7
  72. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  73. data/lib/samagotchi/tools/builtins.rb +5 -0
  74. data/lib/samagotchi/tools/context_read.rb +163 -0
  75. data/lib/samagotchi/tools/delegate.rb +13 -4
  76. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  77. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  78. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  79. data/lib/samagotchi/tools/task_wait.rb +2 -2
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +117 -3
  82. data/lib/samagotchi/web/public/app.js +52 -14
  83. data/lib/samagotchi/web/public/composer_size.js +7 -6
  84. data/lib/samagotchi/web/public/context_chips.js +285 -0
  85. data/lib/samagotchi/web/public/data.js +21 -0
  86. data/lib/samagotchi/web/public/format.js +8 -1
  87. data/lib/samagotchi/web/public/index.html +49 -3
  88. data/lib/samagotchi/web/public/notify.js +6 -2
  89. data/lib/samagotchi/web/public/timing.js +7 -3
  90. data/lib/samagotchi/web/public/turn_events.js +34 -10
  91. data/lib/samagotchi/web/public/turn_view.js +26 -4
  92. data/lib/samagotchi/worker.rb +343 -8
  93. metadata +16 -1
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "securerandom"
5
+ require "time"
4
6
 
7
+ require_relative "config"
5
8
  require_relative "events"
6
9
  require_relative "session"
7
10
  require_relative "session_inbox"
@@ -17,6 +20,9 @@ require_relative "continue_offer"
17
20
  require_relative "iteration_limit"
18
21
  require_relative "session_commands"
19
22
  require_relative "model_profile"
23
+ require_relative "child_reports"
24
+ require_relative "context_absorber"
25
+ require_relative "context_poller"
20
26
  require_relative "tools/task_runtime"
21
27
 
22
28
  module Samagotchi
@@ -45,6 +51,14 @@ module Samagotchi
45
51
  # turn) is refused as busy.
46
52
  class Worker
47
53
  FALLBACK_TICK_SECONDS = 5
54
+ # A worker just started runs no wake turn for delegate reports this
55
+ # long: a worker spawned for a message (chi send to a stopped parent)
56
+ # gets it over the Bridge a moment after it starts, and that message's
57
+ # turn goes first (the reports join it).
58
+ WAKE_START_GRACE = 2.0
59
+ # One context wake per source in this long (D5): later changes inside
60
+ # it arrive as notes.
61
+ CONTEXT_WAKE_WINDOW = 600
48
62
  # How much of a command's output goes into its :command_ran.
49
63
  COMMAND_OUTPUT_LIMIT = 4096
50
64
  BUSY_OUTPUT = "busy: wait for the turn to end"
@@ -89,6 +103,25 @@ module Samagotchi
89
103
  @discard = false
90
104
  # The exit asked for is a restart (POST /exit restart: true).
91
105
  @exit_restart = false
106
+ # The lines the running turn's drain merged ([prompt, origin]), for a
107
+ # failed prompt turn to hand back; reset as each turn begins.
108
+ @merged_this_turn = []
109
+ # A delegate child's side (ChildRing): whether the running turn is
110
+ # its parent's (it rings when it ends), whether that turn rang a
111
+ # question, and whether the next turn rings whatever its origin (it
112
+ # follows a question the parent was rung about: D10).
113
+ @reporting_turn = false
114
+ @asked_parent = false
115
+ @owes_parent = false
116
+ @initial_turn = false
117
+ # A parent's side: turns run for delegate reports since the last human
118
+ # input (session.max_wakes), whether a failed one paused them until
119
+ # then, whether the budget notice went out, and the reports a wake
120
+ # turn's first boundary hands over.
121
+ @wakes_in_a_row = 0
122
+ @wakes_paused = false
123
+ @budget_noticed = false
124
+ @wake_reports = nil
92
125
  end
93
126
 
94
127
  # Whether the session was empty as the worker left it, so the caller
@@ -96,6 +129,12 @@ module Samagotchi
96
129
  # checks again then).
97
130
  def discard? = @discard
98
131
 
132
+ # Whether delegate reports wait that an idle parent runs a turn for
133
+ # (#wake_state): SessionManager.run_session_loop starts a new worker
134
+ # for them after this one leaves. Reports the budget or a failure held
135
+ # back wait for the next human input instead.
136
+ def wake_due? = wake_state == :due
137
+
99
138
  # What a new session starts on (and /model resets to); nil before #run.
100
139
  attr_reader :default_model
101
140
 
@@ -105,6 +144,7 @@ module Samagotchi
105
144
  # (chi stop), :crashed when the loop raised (the session is marked
106
145
  # errored); SessionManager.run_session_loop turns it into the exit
107
146
  def run
147
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
148
  @session = Session.load(@session_id, state_dir: @state_dir)
109
149
  drop_dead_question
110
150
  @engine = build_engine
@@ -114,7 +154,7 @@ module Samagotchi
114
154
  @engine.session = @session
115
155
  # Input already waiting in the inbox starts the next turn at once: no
116
156
  # turn-end warm-up then.
117
- @engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? }
157
+ @engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? || wake_due? }
118
158
  @turn_flow = TurnFlow.new(engine: @engine)
119
159
  # Its question's answer is queued as the matching /continue (the
120
160
  # Bridge exists by the time anyone answers).
@@ -131,7 +171,14 @@ module Samagotchi
131
171
  @turn_end_seq = 0
132
172
  @engine.subscribe(observer: lambda { |event|
133
173
  @turn_end_seq = event[:event_seq] if Events::TURN_END.include?(event[:type])
174
+ @turn_id = event[:turn_id] if event[:type] == :turn_started
175
+ ring_question(event) if event[:type] == :question_requested
134
176
  })
177
+ # This session's own delegate children's news (rings in children/).
178
+ @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir)
179
+ # Its attached context's notes (ContextSources).
180
+ @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
181
+ project_root: @session.project_root)
135
182
  # /model's default is the config's, as in the REPL; the Engine started
136
183
  # on the session's model.
137
184
  @default_model = ModelProfile.required_model_name(nil)
@@ -175,6 +222,11 @@ module Samagotchi
175
222
  # the background, shown by the UIs; a turn waits only for the ones
176
223
  # that bring tools.
177
224
  @engine.start_init_tasks!
225
+ # Attached context's commands, in the background; a new text wakes
226
+ # the loop to absorb it. Not activity: the idle exit stops it.
227
+ @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
228
+ project_root: @session.project_root, cwd: @session.working_directory,
229
+ on_change: -> { @waker.wake }).start
178
230
  loop do
179
231
  # Check if the session was externally marked as stopped. Not
180
232
  # stopped_on_disk?, which reads a vanished file as "not stopped":
@@ -187,20 +239,24 @@ module Samagotchi
187
239
  next if run_queued_commands
188
240
 
189
241
  # Between turns, so the next turn (the first one too) sees them.
242
+ # A change that asks to wake may start a turn here (C4), but not
243
+ # ahead of an exit or restart a client asked for: it is a note then.
190
244
  absorb_notes
245
+ next if absorb_context(wake: nothing_queued? && !@exit_requested)
191
246
 
192
247
  if (prompt = take_initial_prompt)
193
- run_prompt(prompt, nil) unless initial_command(prompt)
248
+ run_initial_prompt(prompt) unless initial_command(prompt)
194
249
  next
195
250
  end
196
251
 
197
252
  input_files = SessionInbox.find_new_input_files(@session_dir)
198
253
  if input_files.empty?
254
+ next if run_wake_turn
199
255
  next if run_due_reminders
200
256
  return left(:idle_exit) if @idle_exit.due? && leave_idle
201
257
  return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
202
258
 
203
- @waker.wait(@poll_interval)
259
+ @waker.wait(idle_wait)
204
260
  next
205
261
  end
206
262
 
@@ -216,6 +272,8 @@ module Samagotchi
216
272
  # Its stderr is /dev/null: the log is the only trace of why.
217
273
  Log.exception(:worker, "crashed", e)
218
274
  Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
275
+ # A delegate's parent hears of it (best effort).
276
+ ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
219
277
  :crashed
220
278
  ensure
221
279
  # The anytime commands finish and the plugins' services stop (a
@@ -224,6 +282,9 @@ module Samagotchi
224
282
  # step-limit question stays in the file (a save here would write
225
283
  # over a stop's status): with no live worker the lists don't read
226
284
  # it as waiting, and the next worker drops it (drop_dead_question).
285
+ # The context poller first: a command it runs goes (its process
286
+ # group) before the Engine does.
287
+ @context_poller&.stop
227
288
  @engine&.shutdown
228
289
  @bridge&.stop
229
290
  end
@@ -333,6 +394,117 @@ module Samagotchi
333
394
  claimed.each { |file| FileUtils.rm_f(file) }
334
395
  end
335
396
 
397
+ # The attached context's notes (ContextAbsorber), between turns. Not
398
+ # into a session with no turn yet (+before_turn+: one is about to
399
+ # run): auto-attached context alone doesn't make a session worth
400
+ # keeping. Saved, then the subscriptions written (a crash between the
401
+ # two re-delivers; the note ids dedupe).
402
+ # +wake+: the session is idle with nothing queued, so an update whose
403
+ # source asked to wake may start a turn (#context_wake_for): its note
404
+ # goes in as the turn's first message.
405
+ # @return [Boolean] whether a wake turn ran
406
+ def absorb_context(before_turn: false, wake: false)
407
+ return false unless before_turn || conversation_started?
408
+ return false if stopped_on_disk?
409
+
410
+ batch = @context_absorber.pending
411
+ return false unless batch
412
+
413
+ waking = wake ? context_wake_for(batch) : nil
414
+ turn_id = SecureRandom.uuid if waking
415
+ batch.deliveries.each do |delivery|
416
+ next unless delivery.note
417
+
418
+ note = delivery == waking ? delivery.wake_note.merge(turn_start: true, turn_id: turn_id) : delivery.note
419
+ @engine.add_context_note(@session, note)
420
+ end
421
+ # Saved even when every note was there already: after a failed save
422
+ # they are in memory only.
423
+ return false if batch.notes.any? && !save_or_log(:context) { @session.save(state_dir: @state_dir) }
424
+
425
+ @context_absorber.commit(waking ? woken(batch, waking) : batch)
426
+ return false unless waking
427
+
428
+ run_context_wake_turn(waking.name, turn_id)
429
+ true
430
+ rescue SystemCallError, IOError => e
431
+ Log.exception(:worker, "context_failed", e)
432
+ false
433
+ end
434
+
435
+ # No input file or queued command waits: the loop would go idle.
436
+ def nothing_queued? = @command_queue.empty? && SessionInbox.find_new_input_files(@session_dir).empty?
437
+
438
+ # The update in +batch+ that may start a wake turn now: its source
439
+ # asked (wake: true), context.wake is on, the shared wake step's
440
+ # conditions hold (no continue offer, wakes not paused by a failed one,
441
+ # under session.max_wakes in a row with no human input, past the start
442
+ # grace) and the source hasn't woken the session in the last
443
+ # CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
444
+ # grace a change is a note: it came while the session was away.
445
+ def context_wake_for(batch, now: Time.now)
446
+ candidates = batch.deliveries.select(&:wake_note)
447
+ return nil if candidates.empty? || !context_wakes_on?
448
+ return nil if @wakes_paused || @turn_flow.awaiting_continue? || wake_grace_left.positive?
449
+
450
+ if @wakes_in_a_row >= max_wakes
451
+ Log.info(:worker, "context_wake_held", reason: "max_wakes", names: candidates.map(&:name).join(","))
452
+ return nil
453
+ end
454
+ candidates.find { |delivery| !woke_lately?(delivery.subscription, now) }
455
+ end
456
+
457
+ def context_wakes_on? = Config.get("context.wake") != false
458
+
459
+ def woke_lately?(subscription, now)
460
+ at = subscription&.wakes_at && Time.iso8601(subscription.wakes_at)
461
+ at ? now - at < CONTEXT_WAKE_WINDOW : false
462
+ rescue ArgumentError
463
+ false
464
+ end
465
+
466
+ # +batch+ with +waking+'s subscription recording the wake (the 10-minute window).
467
+ def woken(batch, waking)
468
+ stamped = waking.with(subscription: waking.subscription.with(wakes_at: Time.now.iso8601))
469
+ batch.with(deliveries: batch.deliveries.map { |delivery| delivery == waking ? stamped : delivery })
470
+ end
471
+
472
+ # A turn nobody typed for a changed source (a context wake): a continue
473
+ # turn from context:<name> whose first message is the wake note already
474
+ # in the conversation (named by +turn_id+), on the same rules as a
475
+ # delegate report's wake turn: it counts toward session.max_wakes, and
476
+ # a failure goes back to before the turn (the note stays: it was saved
477
+ # before the checkpoint) and pauses wakes until a human's input.
478
+ def run_context_wake_turn(name, turn_id)
479
+ @wakes_in_a_row += 1
480
+ Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes_in_a_row)
481
+ @turn_flow.before_prompt_turn
482
+ run_engine_turn(nil, continue: true, origin: { client_id: "#{Steer::CONTEXT_CLIENT_PREFIX}#{name}" },
483
+ id: turn_id, max_iterations: IterationLimit.for) do |result, error|
484
+ if error
485
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
486
+ @turn_flow.prompt_turn_failed(note: note)
487
+ @wakes_paused = true
488
+ else
489
+ @continue_offer.after_turn(result)
490
+ end
491
+ end
492
+ end
493
+
494
+ # Whether the session has had a turn: a user or assistant message.
495
+ def conversation_started?
496
+ Array(@session.messages).any? { |m| %w[user assistant model].include?((m[:role] || m["role"]).to_s) }
497
+ end
498
+
499
+ # The first prompt (spawn_session's): a delegate child's task, which is
500
+ # its parent's although no client sent it.
501
+ def run_initial_prompt(prompt)
502
+ @initial_turn = true
503
+ run_prompt(prompt, nil)
504
+ ensure
505
+ @initial_turn = false
506
+ end
507
+
336
508
  def run_input_file(input_file)
337
509
  claimed_file = SessionInbox.claim_input_file(input_file)
338
510
  return unless claimed_file
@@ -352,10 +524,11 @@ module Samagotchi
352
524
  # its continue turn
353
525
  # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
354
526
  def run_prompt(prompt, origin, no_interrupt: false, images: [])
527
+ # A first turn sees the context attached before it.
528
+ absorb_context(before_turn: true)
355
529
  user_input(origin&.dig(:client_id))
356
530
  @continue_offer.drop(origin)
357
531
  @turn_flow.before_prompt_turn
358
- @merged_this_turn = []
359
532
  run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
360
533
  images: images) do |result, error|
361
534
  if error
@@ -387,6 +560,85 @@ module Samagotchi
387
560
  true
388
561
  end
389
562
 
563
+ # :due when an idle parent should run a turn for its delegate
564
+ # children's reports now (session.delegate_reports: wake): rings wait,
565
+ # no continue offer does (they merge into its turn), wakes aren't paused
566
+ # by a failed one, and the wake budget isn't spent; :budget when only
567
+ # the budget stops it; nil otherwise.
568
+ def wake_state
569
+ return nil unless @child_reports && ChildRing.mode == "wake"
570
+ return nil if @wakes_paused || @turn_flow&.awaiting_continue?
571
+ return nil unless @child_reports.waiting?
572
+
573
+ @wakes_in_a_row < max_wakes ? :due : :budget
574
+ end
575
+
576
+ # Run a turn nobody typed for the reports the rings bring (a wake turn):
577
+ # a continue turn (no prompt bubble) whose first boundary merges them,
578
+ # with origin child:<id8>. A failure goes back to the checkpoint, keeps
579
+ # the rings and pauses wakes until a human's input (a provider outage
580
+ # doesn't loop).
581
+ # @return [Boolean] whether a wake turn ran
582
+ def run_wake_turn
583
+ return false if wake_grace_left.positive?
584
+
585
+ state = wake_state
586
+ budget_notice if state == :budget
587
+ return false unless state == :due
588
+
589
+ reports, = take_child_reports
590
+ # The turn must not generate with nothing to say: the drain gets this
591
+ # exact list at its first boundary.
592
+ return false if reports.empty?
593
+
594
+ @wake_reports = reports
595
+ @wakes_in_a_row += 1
596
+ Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes_in_a_row)
597
+ @turn_flow.before_prompt_turn
598
+ run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
599
+ if error
600
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
601
+ @turn_flow.prompt_turn_failed(note: note)
602
+ @wakes_paused = true
603
+ else
604
+ @continue_offer.after_turn(result)
605
+ end
606
+ end
607
+ true
608
+ ensure
609
+ @wake_reports = nil
610
+ end
611
+
612
+ def wake_grace_left
613
+ WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
614
+ end
615
+
616
+ # The idle loop's sleep: the fallback tick, or less while reports wait
617
+ # out the start grace.
618
+ def idle_wait
619
+ left = wake_grace_left
620
+ left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
621
+ end
622
+
623
+ def max_wakes
624
+ value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
625
+ value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
626
+ rescue StandardError
627
+ ChildRing::MAX_WAKES_DEFAULT
628
+ end
629
+
630
+ # The wake budget is spent: the parent's UIs hear once that reports
631
+ # wait for the next message.
632
+ def budget_notice
633
+ return if @budget_noticed
634
+
635
+ @budget_noticed = true
636
+ count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
637
+ @engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
638
+ text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
639
+ "your next message (#{max_wakes} turn#{"s" if max_wakes != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
640
+ end
641
+
390
642
  def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
391
643
 
392
644
  # @return [Boolean] whether any command ran
@@ -496,6 +748,10 @@ module Samagotchi
496
748
  # @yieldparam result [Object, nil] Engine#run_turn's, nil on a failure
497
749
  # @yieldparam error [StandardError, nil]
498
750
  def run_engine_turn(prompt, **turn_args)
751
+ # Every kind of turn merges steering (a reminder turn too, which may be
752
+ # a fresh worker's first), so the list is this turn's from the start.
753
+ @merged_this_turn = []
754
+ @reporting_turn = reports_to_parent?(turn_args[:origin])
499
755
  begin
500
756
  @session.status = Session::STATUS_RUNNING
501
757
  @session.save(state_dir: @state_dir)
@@ -519,6 +775,51 @@ module Samagotchi
519
775
  SessionInbox.write_output(@session_dir, response)
520
776
  end
521
777
  save_or_log(:turn) { save_session }
778
+ settle_child_reports(error)
779
+ ring_parent_after_turn
780
+ end
781
+
782
+ # The delegate reports this turn took: a kept turn moves the cursors on
783
+ # and deletes their rings; a failed one (rolled back) leaves the rings
784
+ # for the next turn.
785
+ def settle_child_reports(error)
786
+ error ? @child_reports.release : @child_reports.commit
787
+ end
788
+
789
+ # A delegate child's turn that was its parent's (#reports_to_parent?)
790
+ # rings the parent, after the save: the parent reads a settled child.
791
+ # A question it rang about that still waits makes the next turn ring
792
+ # too, whoever starts it (the user answering Continue on the web).
793
+ def ring_parent_after_turn
794
+ ChildRing.ring(@session, why: "turn_end", state_dir: @state_dir) if @reporting_turn
795
+ @owes_parent = @asked_parent && !@session.pending_question.nil?
796
+ @asked_parent = false
797
+ @reporting_turn = false
798
+ end
799
+
800
+ # Whether the turn starting now is a delegate child's parent's: its
801
+ # task (the initial prompt), a follow-up, a continue the parent
802
+ # answered, a reminder, anything but a human typing into the child
803
+ # (ArchiveStore.user_input?), and any turn after a question the parent
804
+ # was rung about.
805
+ def reports_to_parent?(origin)
806
+ return false unless @session.delegate?
807
+
808
+ @initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
809
+ end
810
+
811
+ # The Engine published a question (after the question desk saved it).
812
+ # In a turn that reports to the parent, the model's own question and
813
+ # the step-limit continue ring it; approvals and hooks' questions are
814
+ # the user's, on this session's card.
815
+ def ring_question(event)
816
+ return unless @reporting_turn
817
+
818
+ kind = event.dig(:pending_question, :kind).to_s
819
+ return unless ["", ContinueOffer::KIND].include?(kind)
820
+
821
+ ChildRing.ring(@session, why: "question", state_dir: @state_dir)
822
+ @asked_parent = true
522
823
  end
523
824
 
524
825
  # A save between turns (the turn's own, a command's, the notes', the
@@ -588,14 +889,42 @@ module Samagotchi
588
889
  end
589
890
  end
590
891
  merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
591
- unless merged.empty?
592
- @engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
892
+ # Delegate children's reports: never handed back on a failure
893
+ # (their rings stay for the next turn), so not in @merged_this_turn.
894
+ reports, mark = take_child_reports
895
+ unless merged.empty? && reports.empty?
896
+ @engine.announce(type: :input_merged, count: merged.size + reports.size,
897
+ origins: merged.filter_map(&:last) + reports.map(&:origin))
593
898
  @merged_this_turn.concat(merged)
594
899
  end
595
900
  # Each line keeps its sender (Steer.source_for_client): a chi send or
596
901
  # a delegating parent's line is saved and shown to the model as theirs.
597
- merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) }
902
+ lines = merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) } +
903
+ reports.map(&:line)
904
+ # A wake turn starts with its first line, whoever sent it.
905
+ lines[0] = lines[0].with(mark: mark) if mark && lines.any?
906
+ lines
907
+ end
908
+ end
909
+
910
+ # The reports for rings that came in since the turn last looked. A
911
+ # failure to read them must not take the turn's steering with it.
912
+ # A wake turn's first call gets the reports it was started for (their
913
+ # message is the turn's start: turn_start, turn_id, so a reload shows
914
+ # the turn as its own).
915
+ # @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
916
+ # and the mark of their message
917
+ def take_child_reports
918
+ if @wake_reports
919
+ reports = @wake_reports
920
+ @wake_reports = nil
921
+ return [reports, { turn_start: true, turn_id: @turn_id }.compact]
598
922
  end
923
+
924
+ [@child_reports.take, nil]
925
+ rescue StandardError => e
926
+ Log.warn(:worker, "delegate_reports_failed", error: e.class.name, msg: e.message)
927
+ [[], nil]
599
928
  end
600
929
 
601
930
  # Check again with the event log held, which the Bridge holds while it
@@ -696,8 +1025,14 @@ module Samagotchi
696
1025
 
697
1026
  # A human's input (ArchiveStore.user_input?) un-archives the session;
698
1027
  # a delegate's, a plugin's or a reminder's doesn't.
1028
+ # It also resets the delegate-report wakes (budget and pause).
699
1029
  def user_input(client_id)
700
- ArchiveStore.user_input(@session_id, state_dir: @state_dir) if ArchiveStore.user_input?(client_id)
1030
+ return unless ArchiveStore.user_input?(client_id)
1031
+
1032
+ ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1033
+ @wakes_in_a_row = 0
1034
+ @wakes_paused = false
1035
+ @budget_noticed = false
701
1036
  end
702
1037
 
703
1038
  def stopped_on_disk?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -125,6 +125,7 @@ files:
125
125
  - docs/architecture.md
126
126
  - docs/cli.md
127
127
  - docs/configuration.md
128
+ - docs/context.md
128
129
  - docs/desktop.md
129
130
  - docs/guardrails.md
130
131
  - docs/hooks.md
@@ -170,6 +171,9 @@ files:
170
171
  - lib/samagotchi/bundles/check-in/plugin.rb
171
172
  - lib/samagotchi/bundles/core/manifest.yml
172
173
  - lib/samagotchi/bundles/dev/manifest.yml
174
+ - lib/samagotchi/bundles/github-pr/manifest.yml
175
+ - lib/samagotchi/bundles/github-pr/plugin.rb
176
+ - lib/samagotchi/bundles/github-pr/scripts/pr_context.rb
173
177
  - lib/samagotchi/bundles/guardrails/guardrails.md
174
178
  - lib/samagotchi/bundles/guardrails/guardrails/rules.yml
175
179
  - lib/samagotchi/bundles/guardrails/guardrails/small-models.yml
@@ -193,6 +197,7 @@ files:
193
197
  - lib/samagotchi/bundles/system/memory_guide.md
194
198
  - lib/samagotchi/bundles/system/self_map.md
195
199
  - lib/samagotchi/cancellation_controller.rb
200
+ - lib/samagotchi/child_reports.rb
196
201
  - lib/samagotchi/cli/command.rb
197
202
  - lib/samagotchi/cli/exit.rb
198
203
  - lib/samagotchi/cli/flags.rb
@@ -202,8 +207,14 @@ files:
202
207
  - lib/samagotchi/commands/registry.rb
203
208
  - lib/samagotchi/config.rb
204
209
  - lib/samagotchi/config_text_edit.rb
210
+ - lib/samagotchi/context_absorber.rb
211
+ - lib/samagotchi/context_command.rb
212
+ - lib/samagotchi/context_fetch.rb
205
213
  - lib/samagotchi/context_note.rb
214
+ - lib/samagotchi/context_poller.rb
215
+ - lib/samagotchi/context_providers.rb
206
216
  - lib/samagotchi/context_quote.rb
217
+ - lib/samagotchi/context_sources.rb
207
218
  - lib/samagotchi/context_status.rb
208
219
  - lib/samagotchi/context_usage.rb
209
220
  - lib/samagotchi/context_window.rb
@@ -314,6 +325,7 @@ files:
314
325
  - lib/samagotchi/paths.rb
315
326
  - lib/samagotchi/pending_input_queue.rb
316
327
  - lib/samagotchi/plugin/api.rb
328
+ - lib/samagotchi/plugin/attached_context.rb
317
329
  - lib/samagotchi/plugin/context.rb
318
330
  - lib/samagotchi/plugin/loader.rb
319
331
  - lib/samagotchi/plugin/service.rb
@@ -393,7 +405,9 @@ files:
393
405
  - lib/samagotchi/tools/builtin_calls.rb
394
406
  - lib/samagotchi/tools/builtins.rb
395
407
  - lib/samagotchi/tools/cancel_reminder.rb
408
+ - lib/samagotchi/tools/context_read.rb
396
409
  - lib/samagotchi/tools/delegate.rb
410
+ - lib/samagotchi/tools/delegate_cursor.rb
397
411
  - lib/samagotchi/tools/delegate_relay.rb
398
412
  - lib/samagotchi/tools/delegate_result.rb
399
413
  - lib/samagotchi/tools/delegate_wait.rb
@@ -446,6 +460,7 @@ files:
446
460
  - lib/samagotchi/web/public/command_list.js
447
461
  - lib/samagotchi/web/public/command_view.js
448
462
  - lib/samagotchi/web/public/composer_size.js
463
+ - lib/samagotchi/web/public/context_chips.js
449
464
  - lib/samagotchi/web/public/copy.js
450
465
  - lib/samagotchi/web/public/ctx.js
451
466
  - lib/samagotchi/web/public/data.js