samagotchi 0.31.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -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 +3 -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/plugins.md +77 -1
  11. data/docs/releasing.md +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +4 -1
  13. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  16. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  23. data/lib/samagotchi/child_reports.rb +0 -2
  24. data/lib/samagotchi/config.rb +6 -0
  25. data/lib/samagotchi/context_absorber.rb +179 -0
  26. data/lib/samagotchi/context_command.rb +473 -0
  27. data/lib/samagotchi/context_fetch.rb +226 -0
  28. data/lib/samagotchi/context_note.rb +11 -5
  29. data/lib/samagotchi/context_poller.rb +100 -0
  30. data/lib/samagotchi/context_providers.rb +159 -0
  31. data/lib/samagotchi/context_sources.rb +452 -0
  32. data/lib/samagotchi/engine.rb +10 -5
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  37. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  38. data/lib/samagotchi/log_line.rb +1 -1
  39. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  40. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  41. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  42. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  43. data/lib/samagotchi/plugin/context.rb +22 -0
  44. data/lib/samagotchi/question_desk.rb +0 -3
  45. data/lib/samagotchi/session.rb +7 -5
  46. data/lib/samagotchi/session_commands.rb +18 -1
  47. data/lib/samagotchi/session_manager.rb +5 -2
  48. data/lib/samagotchi/session_retention.rb +3 -0
  49. data/lib/samagotchi/steer.rb +12 -3
  50. data/lib/samagotchi/system_prompt.rb +1 -1
  51. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
  52. data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
  53. data/lib/samagotchi/terminal_ui.rb +0 -3
  54. data/lib/samagotchi/tool_activity.rb +8 -0
  55. data/lib/samagotchi/tool_declarations.rb +21 -0
  56. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  57. data/lib/samagotchi/tools/builtins.rb +5 -0
  58. data/lib/samagotchi/tools/context_read.rb +163 -0
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +109 -1
  61. data/lib/samagotchi/web/public/app.js +40 -10
  62. data/lib/samagotchi/web/public/composer_size.js +7 -6
  63. data/lib/samagotchi/web/public/context_chips.js +285 -0
  64. data/lib/samagotchi/web/public/data.js +21 -0
  65. data/lib/samagotchi/web/public/index.html +49 -3
  66. data/lib/samagotchi/web/public/notify.js +5 -3
  67. data/lib/samagotchi/web/public/timing.js +7 -3
  68. data/lib/samagotchi/web/public/turn_events.js +8 -2
  69. data/lib/samagotchi/web/public/turn_view.js +4 -1
  70. data/lib/samagotchi/worker.rb +126 -0
  71. metadata +14 -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"
@@ -18,6 +21,8 @@ require_relative "iteration_limit"
18
21
  require_relative "session_commands"
19
22
  require_relative "model_profile"
20
23
  require_relative "child_reports"
24
+ require_relative "context_absorber"
25
+ require_relative "context_poller"
21
26
  require_relative "tools/task_runtime"
22
27
 
23
28
  module Samagotchi
@@ -51,6 +56,9 @@ module Samagotchi
51
56
  # gets it over the Bridge a moment after it starts, and that message's
52
57
  # turn goes first (the reports join it).
53
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
54
62
  # How much of a command's output goes into its :command_ran.
55
63
  COMMAND_OUTPUT_LIMIT = 4096
56
64
  BUSY_OUTPUT = "busy: wait for the turn to end"
@@ -168,6 +176,9 @@ module Samagotchi
168
176
  })
169
177
  # This session's own delegate children's news (rings in children/).
170
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)
171
182
  # /model's default is the config's, as in the REPL; the Engine started
172
183
  # on the session's model.
173
184
  @default_model = ModelProfile.required_model_name(nil)
@@ -211,6 +222,11 @@ module Samagotchi
211
222
  # the background, shown by the UIs; a turn waits only for the ones
212
223
  # that bring tools.
213
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
214
230
  loop do
215
231
  # Check if the session was externally marked as stopped. Not
216
232
  # stopped_on_disk?, which reads a vanished file as "not stopped":
@@ -223,7 +239,10 @@ module Samagotchi
223
239
  next if run_queued_commands
224
240
 
225
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.
226
244
  absorb_notes
245
+ next if absorb_context(wake: nothing_queued? && !@exit_requested)
227
246
 
228
247
  if (prompt = take_initial_prompt)
229
248
  run_initial_prompt(prompt) unless initial_command(prompt)
@@ -263,6 +282,9 @@ module Samagotchi
263
282
  # step-limit question stays in the file (a save here would write
264
283
  # over a stop's status): with no live worker the lists don't read
265
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
266
288
  @engine&.shutdown
267
289
  @bridge&.stop
268
290
  end
@@ -372,6 +394,108 @@ module Samagotchi
372
394
  claimed.each { |file| FileUtils.rm_f(file) }
373
395
  end
374
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
+
375
499
  # The first prompt (spawn_session's): a delegate child's task, which is
376
500
  # its parent's although no client sent it.
377
501
  def run_initial_prompt(prompt)
@@ -400,6 +524,8 @@ module Samagotchi
400
524
  # its continue turn
401
525
  # @param images [Array<Hash>] the prompt's image refs ({file:, name:})
402
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)
403
529
  user_input(origin&.dig(:client_id))
404
530
  @continue_offer.drop(origin)
405
531
  @turn_flow.before_prompt_turn
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.31.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
@@ -203,8 +207,14 @@ files:
203
207
  - lib/samagotchi/commands/registry.rb
204
208
  - lib/samagotchi/config.rb
205
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
206
213
  - lib/samagotchi/context_note.rb
214
+ - lib/samagotchi/context_poller.rb
215
+ - lib/samagotchi/context_providers.rb
207
216
  - lib/samagotchi/context_quote.rb
217
+ - lib/samagotchi/context_sources.rb
208
218
  - lib/samagotchi/context_status.rb
209
219
  - lib/samagotchi/context_usage.rb
210
220
  - lib/samagotchi/context_window.rb
@@ -315,6 +325,7 @@ files:
315
325
  - lib/samagotchi/paths.rb
316
326
  - lib/samagotchi/pending_input_queue.rb
317
327
  - lib/samagotchi/plugin/api.rb
328
+ - lib/samagotchi/plugin/attached_context.rb
318
329
  - lib/samagotchi/plugin/context.rb
319
330
  - lib/samagotchi/plugin/loader.rb
320
331
  - lib/samagotchi/plugin/service.rb
@@ -394,6 +405,7 @@ files:
394
405
  - lib/samagotchi/tools/builtin_calls.rb
395
406
  - lib/samagotchi/tools/builtins.rb
396
407
  - lib/samagotchi/tools/cancel_reminder.rb
408
+ - lib/samagotchi/tools/context_read.rb
397
409
  - lib/samagotchi/tools/delegate.rb
398
410
  - lib/samagotchi/tools/delegate_cursor.rb
399
411
  - lib/samagotchi/tools/delegate_relay.rb
@@ -448,6 +460,7 @@ files:
448
460
  - lib/samagotchi/web/public/command_list.js
449
461
  - lib/samagotchi/web/public/command_view.js
450
462
  - lib/samagotchi/web/public/composer_size.js
463
+ - lib/samagotchi/web/public/context_chips.js
451
464
  - lib/samagotchi/web/public/copy.js
452
465
  - lib/samagotchi/web/public/ctx.js
453
466
  - lib/samagotchi/web/public/data.js