samagotchi 0.138.0 → 0.139.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/docs/architecture.md +33 -7
  4. data/docs/cli.md +6 -2
  5. data/docs/configuration.md +60 -5
  6. data/docs/internals/background-tasks.md +3 -3
  7. data/docs/internals/engine.md +2 -1
  8. data/docs/internals/tool-guardrails.md +2 -2
  9. data/docs/internals/web-push.md +20 -0
  10. data/docs/sessions.md +5 -3
  11. data/lib/samagotchi/bundle_command.rb +21 -21
  12. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/mcp/plugin.rb +8 -3
  14. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/self_map.md +2 -2
  16. data/lib/samagotchi/cancellation_controller.rb +4 -4
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_fetch.rb +2 -2
  19. data/lib/samagotchi/cut.rb +21 -0
  20. data/lib/samagotchi/cut_policy.rb +6 -5
  21. data/lib/samagotchi/engine.rb +4 -2
  22. data/lib/samagotchi/engine_peers.rb +2 -1
  23. data/lib/samagotchi/hook_routes.rb +3 -2
  24. data/lib/samagotchi/kernel_loop.rb +8 -6
  25. data/lib/samagotchi/launch_mode.rb +5 -2
  26. data/lib/samagotchi/llm/chat_loop.rb +3 -2
  27. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  28. data/lib/samagotchi/memory_bundle/status.rb +13 -3
  29. data/lib/samagotchi/project_env.rb +16 -2
  30. data/lib/samagotchi/reply_wait/poll.rb +9 -2
  31. data/lib/samagotchi/reply_wait.rb +9 -3
  32. data/lib/samagotchi/steer_cut.rb +2 -1
  33. data/lib/samagotchi/task_finish_note.rb +8 -5
  34. data/lib/samagotchi/task_watch.rb +11 -6
  35. data/lib/samagotchi/testing/boundary.rb +3 -1
  36. data/lib/samagotchi/tool_activity.rb +3 -3
  37. data/lib/samagotchi/tool_declarations.rb +2 -2
  38. data/lib/samagotchi/tools/builtins.rb +2 -1
  39. data/lib/samagotchi/tools/delegate_wait.rb +23 -2
  40. data/lib/samagotchi/tools/execute.rb +51 -9
  41. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  42. data/lib/samagotchi/turn_note.rb +6 -6
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +30 -158
  45. data/lib/samagotchi/web/auto_update.rb +282 -0
  46. data/lib/samagotchi/web/public/app.js +6 -4
  47. data/lib/samagotchi/web/public/composer.css +1 -1
  48. data/lib/samagotchi/web/public/index.html +10 -9
  49. data/lib/samagotchi/web/public/notifications.js +10 -1
  50. data/lib/samagotchi/web/public/revise_stack.js +6 -5
  51. data/lib/samagotchi/web/public/task_stop.js +3 -3
  52. data/lib/samagotchi/web/public/update_notice.js +10 -4
  53. data/lib/samagotchi/web/public/update_sheet.js +3 -2
  54. data/lib/samagotchi/web/push/notifier.rb +66 -5
  55. data/lib/samagotchi/web/server.rb +45 -1
  56. data/lib/samagotchi/web/session_access_routes.rb +1 -1
  57. data/lib/samagotchi/web/session_hub.rb +20 -3
  58. data/lib/samagotchi/web/update_check.rb +56 -10
  59. data/lib/samagotchi/web/update_ops.rb +240 -0
  60. data/lib/samagotchi/worker_task_wakes.rb +1 -1
  61. metadata +4 -1
@@ -454,10 +454,15 @@ class Plugin
454
454
  client
455
455
  end
456
456
 
457
- # A server's env: the session's project env (the worker's mise env, on a
458
- # chi that has it: Samagotchi::ProjectEnv) under the server's own keys.
457
+ # A server's env: the session's project env as execute's commands get it
458
+ # (the worker's mise env without chi's own Ruby and Bundler setup, on a
459
+ # chi that has it: Samagotchi::ProjectEnv.spawn_env) under the server's
460
+ # own keys. An older chi has only the overlay, or none.
459
461
  def server_env(server)
460
- project = defined?(Samagotchi::ProjectEnv) ? Samagotchi::ProjectEnv.current.env : {}
462
+ project = if !defined?(Samagotchi::ProjectEnv) then {}
463
+ elsif Samagotchi::ProjectEnv.respond_to?(:spawn_env) then Samagotchi::ProjectEnv.spawn_env
464
+ else Samagotchi::ProjectEnv.current.env
465
+ end
461
466
  project.merge(server.env)
462
467
  end
463
468
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.138.0
3
+ version: 0.139.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
8
+ self_map.md: sha256:3253cb9ae7f09821d8fe1fed5154dd2b5e33fe0e9f096eee2af981d7d7d66f16
9
9
  config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
10
10
  memory_guide.md: sha256:3b9ff779ee962f1307d56937c826d5e581923114f3f39455b779ca618b363cd7
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -31,8 +31,8 @@
31
31
  asking for changes, checks failing, the PR closed), chi may start a turn for it with no user
32
32
  message ("context <name> changed"): I tell my user what changed and act on nothing.
33
33
  - `[TASK FINISHED from task <id8>, …]` is a background task I started (`task_create`, an `execute`
34
- handed over at its limit) that finished while I wasn't waiting on it: its exit code, duration
35
- and log tail. When it opens a turn with no user message ("task <id8> finished"), I carry on with
34
+ handed over at its limit, or early when a message came for my turn) that finished while I wasn't
35
+ waiting on it: its exit code, duration and log tail. When it opens a turn with no user message ("task <id8> finished"), I carry on with
36
36
  what I planned for its result, or tell my user in a sentence or two how it went. Without the
37
37
  "chi started this turn" line it is only information for my next answer.
38
38
  - `forget_outputs` (only when my model's `llm_context.strategy` has the experimental forget layer)
@@ -20,8 +20,8 @@ module Samagotchi
20
20
  @generation = nil
21
21
  end
22
22
 
23
- # @param detail [Object, nil] what the canceller says about it (a cut
24
- # generation's {by:, reason:})
23
+ # @param detail [Object, nil] what the canceller says about it (the Cut
24
+ # of a cut generation, or a turn stopped by a hook)
25
25
  def cancel!(reason = :manual, detail = nil)
26
26
  listeners = []
27
27
  @mutex.synchronize do
@@ -54,11 +54,11 @@ module Samagotchi
54
54
  @mutex.synchronize { @detail }
55
55
  end
56
56
 
57
- # Who stopped it when a hook did: the detail's +by+ (the bundle, or the
57
+ # Who stopped it when a hook did: the Cut's +by+ (the bundle, or the
58
58
  # hook's label); nil for any other cancel.
59
59
  def stopped_by
60
60
  reason, detail = @mutex.synchronize { [@reason, @detail] }
61
- by = detail[:by].to_s if reason == :hook && detail.is_a?(Hash)
61
+ by = detail.by.to_s if reason == :hook && detail.respond_to?(:by)
62
62
  by unless by.nil? || by.empty?
63
63
  end
64
64
 
@@ -293,6 +293,10 @@ module Samagotchi
293
293
  Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
294
294
  Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
295
295
  Entry.new(key: "update.notify", yaml_path: %w[update notify], type: :enum, default: "on", expose: %i[env config], enum_values: %w[on off], lifecycle: :web_start),
296
+ # Unattended installs (chi web, Web::AutoUpdate): install: the release the checker found, once no turn runs anywhere;
297
+ # update.restart then says whether idle workers and chi web restart onto it (idle) or it waits for the owner (never).
298
+ Entry.new(key: "update.auto", yaml_path: %w[update auto], type: :enum, default: "off", expose: %i[env config], enum_values: %w[off install], lifecycle: :web_start),
299
+ Entry.new(key: "update.restart", yaml_path: %w[update restart], type: :enum, default: "idle", expose: %i[env config], enum_values: %w[idle never], lifecycle: :web_start),
296
300
  Entry.new(key: "update.check_hours", yaml_path: %w[update check_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
297
301
  check: ->(value) { "at least 1" unless value >= 1 }),
298
302
  Entry.new(key: "update.min_age_hours", yaml_path: %w[update min_age_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
@@ -109,8 +109,8 @@ module Samagotchi
109
109
 
110
110
  def env_for(location, name)
111
111
  previous = location.snapshot_path(name)
112
- # The session's project env (ProjectEnv) under the source's own keys.
113
- ProjectEnv.tools_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
112
+ # The session's project env (ProjectEnv.spawn_env) under the source's own keys.
113
+ ProjectEnv.spawn_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
114
114
  "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil)
115
115
  end
116
116
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Why a generation was cut: what CancellationController#cancel_generation!
5
+ # is given (a bundle's stop_generation, or Engine#cut_for_steer) and what
6
+ # the loops keep on a cut generation. +by+ is who stopped it (the hook's
7
+ # label, or "steer"), +reason+ what it said, +steer+ true when a queued user
8
+ # message cut it (no nudge, no retry attempt spent), +source+ that message,
9
+ # for the steer_cut row. The same value rides a whole-turn stop
10
+ # (stop_turn): the cancel note reads its +by+ and +reason+.
11
+ Cut = Data.define(:by, :reason, :steer, :source) do
12
+ def initialize(by: nil, reason: nil, steer: false, source: nil) = super
13
+
14
+ # A hook's cut (stop_generation, or stop_turn): the bundle +by+ and its
15
+ # +reason+.
16
+ def self.hook(by: nil, reason: nil) = new(by: by, reason: reason)
17
+
18
+ # Engine#cut_for_steer: a queued message cut the streaming generation.
19
+ def self.steer(source) = new(by: "steer", reason: "a new message", steer: true, source: source)
20
+ end
21
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cut"
3
4
  require_relative "turn_note"
4
5
 
5
6
  module Samagotchi
@@ -30,7 +31,7 @@ module Samagotchi
30
31
 
31
32
  module_function
32
33
 
33
- # +cut+: the cut's detail (the stop_generation payload). +emit+ takes an
34
+ # +cut+: the Cut (what stop_generation passed). +emit+ takes an
34
35
  # event Hash; +inject+ puts queued input in (true when there was any).
35
36
  # +finish_reason+ and +thinking_chars+: the cut generation's, for the
36
37
  # retry's row.
@@ -40,13 +41,13 @@ module Samagotchi
40
41
  return STOPPED if cancel_controller.cancelled?
41
42
 
42
43
  # The row under the cut step, above the message it was cut for.
43
- emit.call({ type: :steer_cut, iteration: iteration, source: cut[:source].to_s }) if cut[:steer]
44
- return AGAIN if inject.call || cut[:steer]
44
+ emit.call({ type: :steer_cut, iteration: iteration, source: cut.source.to_s }) if cut.steer
45
+ return AGAIN if inject.call || cut.steer
45
46
 
46
47
  if empty_retry.left?
47
- empty_retry.nudge!(conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
48
+ empty_retry.nudge!(conversation, TurnNote.cut_retry(cut.by, cut.reason),
48
49
  emit: emit, iteration: iteration, finish_reason: finish_reason,
49
- thinking_chars: thinking_chars, stopped_by: cut[:by])
50
+ thinking_chars: thinking_chars, stopped_by: cut.by)
50
51
  return AGAIN
51
52
  end
52
53
  empty_retry.drop_nudge!(conversation)
@@ -671,7 +671,8 @@ module Samagotchi
671
671
  end
672
672
 
673
673
  # Whether a message a person sent waits for the running turn's next
674
- # boundary (run_turn's input_waiting: probe): a task_wait returns on it
674
+ # boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
675
+ # returns on it, an execute hands its command over early
675
676
  # (PeerView#input_waiting?). False outside a turn, for a turn given no
676
677
  # probe, and when the probe raises (warned once a turn; the tool never
677
678
  # fails for it).
@@ -2242,7 +2243,8 @@ module Samagotchi
2242
2243
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
2243
2244
  # @param input_waiting [#call, nil] optional probe: true while a message
2244
2245
  # a person sent waits for the next boundary (it peeks, never drains);
2245
- # a running task_wait returns on it (#input_waiting?). nil: never.
2246
+ # a running task_wait or delegate wait returns on it, a running execute
2247
+ # hands its command over (#input_waiting?). nil: never.
2246
2248
  # @param continue [Boolean] resume the conversation without appending a
2247
2249
  # user prompt (continue after the iteration limit, reminder turns);
2248
2250
  # +prompt+ is ignored and :turn_started carries `continue: true`
@@ -9,7 +9,8 @@ module Samagotchi
9
9
  # is the running turn's cancel, for a tool that waits (delegate_result):
10
10
  # the controller is set from another thread and a tool gets no other
11
11
  # way to see it. input_waiting? is whether a person's message waits for
12
- # the running turn's next boundary (task_wait returns on it).
12
+ # the running turn's next boundary (task_wait and the delegate tools' waits
13
+ # return on it, execute hands its command over early).
13
14
  PeerView = Struct.new(:engine) do
14
15
  def session_id = engine.session&.id
15
16
  def cwd = engine.session&.working_directory
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cut"
3
4
  require_relative "event_routes"
4
5
  require_relative "hooks"
5
6
  require_relative "log"
@@ -104,7 +105,7 @@ module Samagotchi
104
105
  return false unless ctrl && !ctrl.cancelled?
105
106
 
106
107
  notify("stopped the turn: #{reason}", :warn, hook)
107
- ctrl.cancel!(:hook, { by: source(hook), reason: reason.to_s })
108
+ ctrl.cancel!(:hook, Cut.hook(by: source(hook), reason: reason.to_s))
108
109
  end
109
110
 
110
111
  # Cut the streaming generation (reason :hook); the turn goes on and asks
@@ -115,7 +116,7 @@ module Samagotchi
115
116
  ctrl = @cancel_controller.call
116
117
  return false unless ctrl
117
118
 
118
- ctrl.cancel_generation!(:hook, { by: source(hook), reason: reason.to_s })
119
+ ctrl.cancel_generation!(:hook, Cut.hook(by: source(hook), reason: reason.to_s))
119
120
  end
120
121
 
121
122
  private
@@ -13,6 +13,7 @@ require_relative "client"
13
13
  require_relative "llm/errors"
14
14
  require_relative "log"
15
15
  require_relative "empty_answer_retry"
16
+ require_relative "cut"
16
17
  require_relative "cut_policy"
17
18
  require_relative "thinking"
18
19
  require_relative "hooks"
@@ -327,8 +328,9 @@ module Samagotchi
327
328
  # One request: the prompt and its images as sent, the images' token
328
329
  # estimate, and the window it was measured against.
329
330
  Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
330
- # One generation: the response (nil when cut), the cut's detail, this
331
- # generation's own server counts, the model the server named, the
331
+ # One generation: the response (nil when cut), the Cut that stopped it
332
+ # (an empty one when nothing said why), this generation's own server
333
+ # counts, the model the server named, the
332
334
  # thinking it streamed, and why it stopped (llama.cpp's finish
333
335
  # reason, Client.finish_reason_from; nil when not named),
334
336
  # and its prompt-cache counts (the largest seen: they come cumulative),
@@ -472,7 +474,7 @@ module Samagotchi
472
474
  rescue Client::RequestCancelled
473
475
  raise unless cut?(generation_controller, turn.cancel_controller)
474
476
 
475
- generation.cut = generation_controller.detail || {}
477
+ generation.cut = generation_controller.detail || Cut.new
476
478
  nil
477
479
  end
478
480
  generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
@@ -634,8 +636,8 @@ module Samagotchi
634
636
  turn.buffer.slice!(buffer_mark..)
635
637
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: 0,
636
638
  thinking_chars: generation.streamed_thinking, served_model: generation.served_model,
637
- requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut[:by],
638
- stop_reason: generation.cut[:reason])
639
+ requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut.by,
640
+ stop_reason: generation.cut.reason)
639
641
  end
640
642
 
641
643
  def completed_generation(turn, request, generation)
@@ -668,7 +670,7 @@ module Samagotchi
668
670
  raise Client::RequestCancelled, turn.cancel_controller.reason if outcome.stopped?
669
671
  return :next if outcome.again?
670
672
 
671
- emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut[:by])
673
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut.by)
672
674
  cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
673
675
  end
674
676
 
@@ -17,8 +17,11 @@ module Samagotchi
17
17
  # into a kept one.
18
18
  SCRATCH_LEFTOVER = "that's a leftover scratch session; it is deleted at the next sweep (chi sessions clean)"
19
19
  # A worker spawned or resumed for an attach whose Bridge never came (%s:
20
- # the session id).
21
- BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
20
+ # the session id). A worker whose chi home couldn't load chi logs
21
+ # SessionManager::HOME_BOOT_FAILED and exits: HomeDoctor's boot row says
22
+ # what and what to run, so the text names chi home doctor.
23
+ BRIDGE_LATE = "the worker for session %s did not start its Bridge in time " \
24
+ "(chi home doctor shows a worker that failed to boot)"
22
25
  # An attach (or a resume) that found the session's worker leaving, its
23
26
  # Bridge closed and its recap on leave still being written: it waits for
24
27
  # the worker to let go (OwnerLock.await_release), then starts a new one.
@@ -14,6 +14,7 @@ require_relative "stream_drops"
14
14
  require_relative "../kernel_loop"
15
15
  require_relative "../answer_display"
16
16
  require_relative "../answer_refs"
17
+ require_relative "../cut"
17
18
  require_relative "../cut_policy"
18
19
  require_relative "../context_status"
19
20
  require_relative "../context_window"
@@ -763,7 +764,7 @@ module Samagotchi
763
764
  rescue RequestCancelled
764
765
  raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
765
766
 
766
- return cut_response(iteration, thought, generation_controller.detail || {})
767
+ return cut_response(iteration, thought, generation_controller.detail || Cut.new)
767
768
  end
768
769
  dropped = settle_thinking_drop(iteration, response.thinking_drop)
769
770
  record_context_status(response.usage, window)
@@ -820,7 +821,7 @@ module Samagotchi
820
821
  # stream never sent its last chunk).
821
822
  def cut_response(iteration, thought, cut)
822
823
  emit(type: :generation_completed, iteration: iteration, content_length: 0, thinking_chars: thought.length,
823
- requested_model: @model_name, finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
824
+ requested_model: @model_name, finish_reason: "stopped", stopped_by: cut.by, stop_reason: cut.reason)
824
825
  response = ChatResponse.new(text: "", reasoning: thought, tool_calls: [], usage: nil, finish_reason: "stopped",
825
826
  cut: cut)
826
827
  [response, nil]
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # +provider+ is the provider that served it (the body's `provider`,
27
27
  # OpenRouter's routing name); nil when the server says nothing.
28
28
  # +cut+ is set on the empty response the chat loop makes for a
29
- # generation a plugin cut (stop_generation): {by:, reason:}.
29
+ # generation a plugin cut (stop_generation): the Cut.
30
30
  # +top_logprobs+: the first answer token's likeliest alternatives
31
31
  # (TokenLogprob) when the request asked for logprobs and the server
32
32
  # sent them (a non-streamed answer only); [] otherwise.
@@ -12,6 +12,16 @@ require_relative "../model_overlay"
12
12
  module Samagotchi
13
13
  module MemoryBundle
14
14
  module Status
15
+ # A bundle as this chi reads it now (what chi bundle status prints):
16
+ # its stored record, the scope it resolves to (scope_error when it
17
+ # can't), the folder its files belong in, what each file looks like on
18
+ # disk, its installed plugin, why none of its hooks load, and its needs
19
+ # with what this process's PATH found. The +files+ entries and +plugin+
20
+ # stay Hashes: their fields differ per case (an unchecked file has no
21
+ # paths at all).
22
+ Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
23
+ :hooks_requires_failure, :needs)
24
+
15
25
  # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
16
26
  # guessed: scope_error says so, target_dir is nil and each file is
17
27
  # unchecked (no disk reads, nothing counted as missing).
@@ -50,9 +60,9 @@ module Samagotchi
50
60
  base_path: base_path
51
61
  }
52
62
  end
53
- { provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
54
- target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
55
- hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle) }
63
+ Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
64
+ target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
65
+ hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle))
56
66
  end
57
67
 
58
68
  # Why none of the bundle's hooks load (this chi doesn't meet its
@@ -50,8 +50,10 @@ module Samagotchi
50
50
  # Keys the tools' overlay never sets: a `chi` a tool runs (the shim's)
51
51
  # reads its config, state and log dirs, its settings and the machine its
52
52
  # session commands go to (CHI_REMOTE) from them, and the test-run flags.
53
- # Dropped, with one warning.
54
- RESERVED_KEYS = %w[HOME RACK_ENV CI CHI_REMOTE].freeze
53
+ # And TMPDIR: the guardrails judge a tool's `$TMPDIR/x` and its tmp dirs
54
+ # (Guardrails::PathResolve.tmp_roots) by the worker's own TMPDIR, so the
55
+ # tools must run on that one too. Dropped, with one warning.
56
+ RESERVED_KEYS = %w[HOME TMPDIR RACK_ENV CI CHI_REMOTE].freeze
55
57
  RESERVED_PREFIXES = %w[XDG_ SAMAGOTCHI_].freeze
56
58
  # Never set from an ORIG_ENV record (a tool can write it), only unset:
57
59
  # loader and Bundler variables.
@@ -112,6 +114,18 @@ module Samagotchi
112
114
  # @return [Hash{String=>String, nil}]
113
115
  def tools_env = current.env.except(*TOOLS_DROP)
114
116
 
117
+ # The whole env change for a command chi starts for the session outside
118
+ # execute (MCP servers, context sources): what execute spawns over
119
+ # (Tools::TaskRuntime.sanitized_spawn_env: chi's own RUBYOPT without
120
+ # bundler/setup, no RUBYLIB or Bundler variables), then #tools_env. One
121
+ # env for every command, so a worker run from a checkout under `bundle
122
+ # exec` hands none of them its Gemfile.
123
+ # @return [Hash{String=>String, nil}]
124
+ def spawn_env
125
+ require_relative "tools/task_runtime" # lazily: bin/chi loads this file for #take_orig!
126
+ Tools::TaskRuntime.sanitized_spawn_env.merge(tools_env)
127
+ end
128
+
115
129
  # Specs: back to NONE.
116
130
  def reset! = @current = NONE
117
131
 
@@ -16,12 +16,13 @@ module Samagotchi
16
16
  # them. ReplyWait.call's parameters say what each means.
17
17
  class Poll
18
18
  def initialize(id, state_dir:, cursor:, timeout:, poll_interval:, cancelled:, baseline:, owner_grace:, interject:,
19
- turn:, queued:, ended_before:, running_look: nil)
19
+ turn:, queued:, ended_before:, running_look: nil, message_waiting: -> { false })
20
20
  @id = id
21
21
  @state_dir = state_dir
22
22
  @cursor = cursor
23
23
  @poll_interval = poll_interval
24
24
  @cancelled = cancelled
25
+ @message_waiting = message_waiting
25
26
  @baseline = baseline
26
27
  @owner_grace = owner_grace
27
28
  @interject = interject
@@ -80,7 +81,7 @@ module Samagotchi
80
81
  follow_merge(session)
81
82
  reply(session) || settled_turn(session) || status_end(session) || question(session) || held(session) || canceled ||
82
83
  ended_before_reply(session) || ended_before_turn(session) || turn_end(session) || owner_gone(session) ||
83
- timed_out
84
+ message_waiting || timed_out
84
85
  end
85
86
 
86
87
  def look_at_input
@@ -189,6 +190,12 @@ module Samagotchi
189
190
 
190
191
  def canceled = @cancelled.call ? Result.new(status: :canceled) : nil
191
192
 
193
+ # A person's message waits for the caller's turn (call's
194
+ # message_waiting:): after every outcome the child can arrive at (a
195
+ # reply, a question, an end, a gone worker) and a cancel, before the
196
+ # deadline. The child isn't touched.
197
+ def message_waiting = @message_waiting.call ? Result.new(status: :message_waiting) : nil
198
+
192
199
  # The turn that ended before this wait (ended_before: chi send --wait
193
200
  # with no message, on a session that went idle in the gap between
194
201
  # waits): its last_turn still stands, the session is idle, so its end
@@ -27,7 +27,8 @@ module Samagotchi
27
27
  TURN_IDS_FEATURE = "turn_ids"
28
28
 
29
29
  # @!attribute status [Symbol] :done, :waiting_for_answer, :no_reply,
30
- # :error, :stopped, :canceled, :timeout or :worker_gone
30
+ # :error, :stopped, :canceled, :message_waiting, :timeout or
31
+ # :worker_gone
31
32
  # @!attribute text [String, nil] the reply (:done) or the worker's error
32
33
  # (:error)
33
34
  # @!attribute file [String, nil] the reply's filename, the next cursor
@@ -159,14 +160,19 @@ module Samagotchi
159
160
  # @param running_look [RunningLook, nil] the running turn an earlier
160
161
  # call of the same wait saw (its :timeout result's running_look): its
161
162
  # settle ends this call too, as if this call had seen it running
163
+ # @param message_waiting [#call] true once a person's message waits for
164
+ # the caller's turn (Engine#input_waiting?): ends the wait early
165
+ # (:message_waiting) when nothing else has, after a cancel, before the
166
+ # deadline. The session waited for is never touched.
162
167
  # @return [Result]
163
168
  # @raise [ArgumentError] no such session
164
169
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
165
170
  baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false,
166
- running_look: nil)
171
+ running_look: nil, message_waiting: -> { false })
167
172
  Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
168
173
  cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
169
- queued: queued, ended_before: ended_before, running_look: running_look).run
174
+ queued: queued, ended_before: ended_before, running_look: running_look,
175
+ message_waiting: message_waiting).run
170
176
  end
171
177
 
172
178
  # The session as it was before a message went in (call's baseline:):
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "cut"
4
5
  require_relative "log"
5
6
  require_relative "steer"
6
7
  require_relative "generation_phase"
@@ -131,7 +132,7 @@ module Samagotchi
131
132
 
132
133
  def cut!(ctrl, source, waited: false)
133
134
  age = @phase.age
134
- cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
135
+ cut = ctrl.cancel_generation!(:steer, Cut.steer(source))
135
136
  if cut
136
137
  @waiting.clear!
137
138
  fields = { source: source, age: age&.round(1) }
@@ -21,24 +21,27 @@ module Samagotchi
21
21
 
22
22
  # @param record [Tools::TaskRecord] the finished task
23
23
  # @param limit_sec [Integer, nil] the limit an execute handed it over at
24
+ # @param early_sec [Integer, nil] the seconds an execute had run when a
25
+ # message from the user handed it over before its limit
24
26
  # @param wake [Boolean] the wake variant
25
27
  # @param cap [Integer] max_tool_output_chars: task_wait's text is cut to
26
28
  # it as ToolRunner cuts the tool's result
27
29
  # @return [Hash] the note (ContextNote.message's keys)
28
- def note(record, limit_sec:, wake:, cap:, turn_id: nil, now: Time.now)
30
+ def note(record, limit_sec:, wake:, cap:, early_sec: nil, turn_id: nil, now: Time.now)
29
31
  note = { note_id: "task-#{record.id}", source: "task #{short_id(record.id)}", header: ContextNote::TASK_HEADER,
30
- text: text(record, limit_sec: limit_sec, wake: wake, cap: cap), created_at: now.iso8601 }
32
+ text: text(record, limit_sec: limit_sec, early_sec: early_sec, wake: wake, cap: cap), created_at: now.iso8601 }
31
33
  wake ? note.merge(turn_start: true, turn_id: turn_id) : note
32
34
  end
33
35
 
34
- def text(record, limit_sec:, wake:, cap:)
35
- [headline(record, limit_sec), "command: #{record.command}", body(record, cap), (WAKE_TEXT if wake)].compact.join("\n")
36
+ def text(record, limit_sec:, wake:, cap:, early_sec: nil)
37
+ [headline(record, limit_sec, early_sec), "command: #{record.command}", body(record, cap), (WAKE_TEXT if wake)].compact.join("\n")
36
38
  end
37
39
 
38
- def headline(record, limit_sec)
40
+ def headline(record, limit_sec, early_sec = nil)
39
41
  seconds = duration(record)
40
42
  outcome = record.exit_code.nil? ? record.status.to_s : "exit #{record.exit_code}"
41
43
  handed = " (handed over by execute at its #{limit_sec}s limit)" if limit_sec
44
+ handed ||= " (handed over by execute after #{early_sec}s: a message from the user was waiting)" if early_sec
42
45
  "Background task #{short_id(record.id)} finished: #{outcome}#{" after #{duration_text(seconds)}" if seconds}#{handed}."
43
46
  end
44
47
 
@@ -19,12 +19,14 @@ module Samagotchi
19
19
  # and anytime commands run on threads of their own.
20
20
  class TaskWatch
21
21
  # A watched task: +limit_sec+, the limit an execute handed it over at
22
- # (nil for task_create's); +note_only+, the user stopped the turn that
22
+ # (nil for task_create's); +early_sec+, the seconds an execute had run
23
+ # when a message from the user handed it over early (nil otherwise);
24
+ # +note_only+, the user stopped the turn that
23
25
  # started or waited on it (M5): its finish is a plain note, no wake;
24
26
  # +failures+, how often its note couldn't be written (#put_back) or the
25
27
  # look for it failed (#look_failed).
26
- Entry = Data.define(:id, :limit_sec, :note_only, :failures) do
27
- def initialize(id:, limit_sec: nil, note_only: false, failures: 0) = super
28
+ Entry = Data.define(:id, :limit_sec, :early_sec, :note_only, :failures) do
29
+ def initialize(id:, limit_sec: nil, early_sec: nil, note_only: false, failures: 0) = super
28
30
  end
29
31
 
30
32
  # A task whose note or look failed this often still gets tried at each
@@ -41,6 +43,7 @@ module Samagotchi
41
43
  TASK_LINE = /\A#{Tools::TaskRuntime::RAN_AS_LINE}(?:\[task_(?:wait|get|stop)\]\n)?task_id: (\S+)/
42
44
  LOOK_TOOLS = %w[task_wait task_get task_stop].freeze
43
45
  HANDED_OVER_LIMIT = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s limit/
46
+ HANDED_OVER_EARLY = /#{Regexp.escape(Tools::Execute::HANDED_OVER)} (\d+)s mark/
44
47
 
45
48
  MODE_KEY = "session.task_wakes"
46
49
  MODES = %w[wake queue off].freeze
@@ -75,16 +78,18 @@ module Samagotchi
75
78
  output = event[:output].to_s
76
79
  output = output.scrub unless output.valid_encoding?
77
80
  if (id = Tools::TaskRuntime.created_id(tool, output))
78
- add(id, limit_sec: tool == Tools::Execute::NAME ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil)
81
+ executed = tool == Tools::Execute::NAME
82
+ add(id, limit_sec: executed ? output[HANDED_OVER_LIMIT, 1]&.to_i : nil,
83
+ early_sec: executed ? output[HANDED_OVER_EARLY, 1]&.to_i : nil)
79
84
  elsif LOOK_TOOLS.include?(tool) && (id = output[TASK_LINE, 1])
80
85
  touched(id)
81
86
  remove(id) if seen?(tool, output)
82
87
  end
83
88
  end
84
89
 
85
- def add(id, limit_sec: nil)
90
+ def add(id, limit_sec: nil, early_sec: nil)
86
91
  @mutex.synchronize do
87
- @entries[id] = Entry.new(id: id, limit_sec: limit_sec)
92
+ @entries[id] = Entry.new(id: id, limit_sec: limit_sec, early_sec: early_sec)
88
93
  @turn_ids << id
89
94
  end
90
95
  end
@@ -103,7 +103,9 @@ module Samagotchi
103
103
  "Samagotchi::TokenUsage::CHARS_PER_TOKEN" => "estimates a tool's tokens: could be a stable constant",
104
104
  'require "samagotchi/tool_declarations"' => "loaded for Api.tool_spec's schemas",
105
105
  "Samagotchi::ProjectEnv" => "checks the worker has a project env (an older chi doesn't)",
106
- "Samagotchi::ProjectEnv.current" => "starts a server in the session's project env: needs a ctx tools-env API"
106
+ "Samagotchi::ProjectEnv.current" => "starts a server in the session's project env: needs a ctx tools-env API",
107
+ "Samagotchi::ProjectEnv.respond_to?" => "checks the worker has ProjectEnv.spawn_env (an older chi doesn't)",
108
+ "Samagotchi::ProjectEnv.spawn_env" => "starts a server in the session's project env: needs a ctx tools-env API"
107
109
  },
108
110
  "slow-commands" => {
109
111
  'require "samagotchi/guardrails/shell_lex"' => "for ShellLex",
@@ -264,8 +264,8 @@ module Samagotchi
264
264
  "ok"
265
265
  end
266
266
 
267
- # An execute still running at its limit, handed over as a task
268
- # (Tools::Execute::HANDED_OVER).
267
+ # An execute still running at its limit (or when a message from the user
268
+ # waited), handed over as a task (Tools::Execute::HANDED_OVER).
269
269
  def handed_over?(tool_name, text)
270
270
  return false unless tool_name == Tools::Execute::NAME && text.to_s.start_with?(Tools::Execute::HANDED_OVER)
271
271
 
@@ -273,7 +273,7 @@ module Samagotchi
273
273
  text.to_s.each_line.first.to_s.scrub.match?(HANDED_OVER_START)
274
274
  end
275
275
 
276
- HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} \d+s limit:/
276
+ HANDED_OVER_START = /\A#{Regexp.escape(Tools::Execute::HANDED_OVER)} #{Tools::Execute::HANDED_OVER_AFTER}/
277
277
 
278
278
  def execute_failed?(text)
279
279
  code = execute_exit_code(text)
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  TOOL_SCHEMAS = [
21
21
  {
22
22
  name: "execute",
23
- description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
23
+ description: "Run any shell command in a working directory (defaults to the project root) and see stdout, stderr, and exit code. Large output may be truncated to a head+tail preview with metadata. A command still running at the time limit, or after a few seconds when a message from the user is waiting, keeps running as a background task (by default): the result gives its task_id, and task_wait waits for it. Don't background with &; use task_create.",
24
24
  parameters: {
25
25
  type: "object",
26
26
  properties: {
@@ -395,7 +395,7 @@ module Samagotchi
395
395
  },
396
396
  {
397
397
  name: "delegate_result",
398
- description: "Wait for a delegated child session's next reply, only when this turn can't go on without it: the child named, or this session's newest running child (name the child when a delegate report is about it). After delegate with wait false you don't need this: chi brings each child's reply by itself as a delegate report, also when you are idle, so end your turn or keep working instead of waiting here. A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
398
+ description: "Wait for a delegated child session's next reply, only when this turn can't go on without it: the child named, or this session's newest running child (name the child when a delegate report is about it). After delegate with wait false you don't need this: chi brings each child's reply by itself as a delegate report, also when you are idle, so end your turn or keep working instead of waiting here. A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer, or a message from the user arrives (the child keeps running). A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
399
399
  parameters: {
400
400
  type: "object",
401
401
  properties: {
@@ -92,6 +92,7 @@ module Samagotchi
92
92
  end,
93
93
  Execute::NAME => lambda do |call, kctx|
94
94
  Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
95
+ message_waiting: input_waiting_proc(kctx),
95
96
  **participant_execute(kctx, project_env(kctx).merge(parent_env(kctx))))
96
97
  end,
97
98
  RegisterReminder::NAME => lambda do |call, kctx|
@@ -202,7 +203,7 @@ module Samagotchi
202
203
 
203
204
  # A person's message waits for the turn's next boundary
204
205
  # (Engine#input_waiting?, through its PeerView): a task_wait returns on
205
- # it. A kernel whose peers can't tell: never.
206
+ # it, an execute hands its command over early. A kernel whose peers can't tell: never.
206
207
  def input_waiting_proc(kctx)
207
208
  peers = kctx.peers
208
209
  -> { peers.respond_to?(:input_waiting?) && !!peers.input_waiting? }