samagotchi 0.32.0 → 0.34.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +8 -1
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +7 -4
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +79 -3
  9. data/docs/releasing.md +3 -2
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/answer_command.rb +2 -1
  12. data/lib/samagotchi/archive_store.rb +1 -15
  13. data/lib/samagotchi/bridge.rb +8 -3
  14. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  15. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  16. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  20. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  23. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  24. data/lib/samagotchi/child_reports.rb +31 -10
  25. data/lib/samagotchi/children_status.rb +157 -0
  26. data/lib/samagotchi/client_id.rb +52 -0
  27. data/lib/samagotchi/context_absorber.rb +2 -1
  28. data/lib/samagotchi/context_fetch.rb +14 -45
  29. data/lib/samagotchi/context_sources.rb +20 -10
  30. data/lib/samagotchi/cut_policy.rb +58 -0
  31. data/lib/samagotchi/engine.rb +24 -108
  32. data/lib/samagotchi/engine_peers.rb +30 -0
  33. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  35. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  36. data/lib/samagotchi/guardrails/context.rb +32 -0
  37. data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
  38. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  39. data/lib/samagotchi/guardrails.rb +1 -0
  40. data/lib/samagotchi/kernel_loop.rb +9 -22
  41. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  42. data/lib/samagotchi/memory_bundle/builder.rb +35 -1
  43. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  44. data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
  45. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  46. data/lib/samagotchi/memory_paths.rb +10 -0
  47. data/lib/samagotchi/plugin/api.rb +2 -2
  48. data/lib/samagotchi/plugin/sessions.rb +47 -2
  49. data/lib/samagotchi/process_group.rb +157 -0
  50. data/lib/samagotchi/prompt_warmup.rb +20 -1
  51. data/lib/samagotchi/question_desk.rb +2 -1
  52. data/lib/samagotchi/relay_verifier.rb +2 -4
  53. data/lib/samagotchi/reply_wait.rb +9 -4
  54. data/lib/samagotchi/send_command.rb +2 -1
  55. data/lib/samagotchi/session_manager.rb +2 -4
  56. data/lib/samagotchi/steer.rb +27 -19
  57. data/lib/samagotchi/steer_cut.rb +125 -0
  58. data/lib/samagotchi/system_prompt.rb +20 -8
  59. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
  60. data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
  61. data/lib/samagotchi/terminal_ui.rb +2 -1
  62. data/lib/samagotchi/tool_declarations.rb +9 -5
  63. data/lib/samagotchi/tools/builtins.rb +2 -2
  64. data/lib/samagotchi/tools/delegate.rb +105 -16
  65. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  66. data/lib/samagotchi/tools/execute.rb +10 -81
  67. data/lib/samagotchi/tools/task_runtime.rb +25 -38
  68. data/lib/samagotchi/turn_note.rb +9 -1
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +2 -1
  71. data/lib/samagotchi/web/public/app.js +19 -20
  72. data/lib/samagotchi/web/public/data.js +3 -0
  73. data/lib/samagotchi/web/public/question_card.js +3 -4
  74. data/lib/samagotchi/web/public/shown_texts.js +13 -0
  75. data/lib/samagotchi/web/public/stable_html.js +18 -0
  76. data/lib/samagotchi/web/public/turn_events.js +9 -2
  77. data/lib/samagotchi/web/public/turn_view.js +1 -1
  78. data/lib/samagotchi/worker.rb +40 -13
  79. metadata +14 -1
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A command run in a process group of its own, so a stop reaches what it
5
+ # started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
6
+ # and ContextFetch spawn and stop their commands here, each with its own
7
+ # grace and its own "has it stopped?" check (the leader gone, the pipes
8
+ # closed, the pid reaped). The mcp bundle's client stops its server here
9
+ # too (after closing its stdin), so it requires chi >= 0.33.0.
10
+ module ProcessGroup
11
+ # A group that is gone, or one that isn't ours to signal.
12
+ QUIET = [Errno::ESRCH, Errno::EPERM].freeze
13
+ READ_CHUNK_BYTES = 65_536
14
+
15
+ module_function
16
+
17
+ # Process.spawn in a new process group, whose id is the pid.
18
+ # @return [Integer] the pid
19
+ def spawn(env, *argv, **) = Process.spawn(env, *argv, pgroup: true, **)
20
+
21
+ # Sends +sig+ to every process in the group +pgid+.
22
+ # @param leader [Boolean] when the group can't be signalled, signal the
23
+ # process +pgid+ alone (a leader that left its group)
24
+ # @param quiet [Array<Class>] the errors that mean there is nothing to
25
+ # signal; any other is raised
26
+ # @return [Boolean] whether a signal was sent
27
+ def signal(pgid, sig, leader: false, quiet: QUIET)
28
+ check!(pgid)
29
+ Process.kill(sig, -pgid)
30
+ true
31
+ rescue *quiet
32
+ return false unless leader
33
+
34
+ begin
35
+ Process.kill(sig, pgid)
36
+ true
37
+ rescue *quiet
38
+ false
39
+ end
40
+ end
41
+
42
+ # A pid chi may signal or ask about: an Integer above 1. kill(0) and
43
+ # kill(-1) reach chi's own group and every process of the user, so a
44
+ # pid that isn't one (a broken or edited record) never gets that far.
45
+ def signalable?(pid) = pid.is_a?(Integer) && pid > 1
46
+
47
+ # @raise [ArgumentError] unless #signalable?
48
+ def check!(pid)
49
+ raise ArgumentError, "not a process group chi may signal: #{pid.inspect}" unless signalable?(pid)
50
+ end
51
+
52
+ # Whether +pid+ runs and leads its own group, as #spawn's process does
53
+ # (not, then, a reused pid in some other group).
54
+ def leader?(pid)
55
+ signalable?(pid) && Process.getpgid(pid) == pid
56
+ rescue Errno::ESRCH, Errno::EPERM
57
+ false
58
+ end
59
+
60
+ # Whether +pid+ is still running (or a zombie not reaped yet).
61
+ # @raise [ArgumentError] unless #signalable?
62
+ def alive?(pid)
63
+ check!(pid)
64
+ Process.kill(0, pid)
65
+ true
66
+ rescue Errno::ESRCH
67
+ false
68
+ rescue Errno::EPERM
69
+ true
70
+ end
71
+
72
+ # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it.
73
+ # @param stopped [#call] true once it has stopped (default: the leader
74
+ # is gone)
75
+ # @param wait [#call] (seconds) between two checks (default: a sleep)
76
+ # @param signal_options [Hash] for #signal (leader:, quiet:)
77
+ # @return [Boolean] true when it stopped within the grace (no KILL sent)
78
+ # @raise [ArgumentError] unless #signalable?
79
+ def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) }, **signal_options)
80
+ check!(pgid)
81
+ signal(pgid, "TERM", **signal_options)
82
+ deadline = monotonic + grace
83
+ until (done = stopped.call) || monotonic >= deadline
84
+ wait.call(poll)
85
+ end
86
+ return true if done
87
+
88
+ signal(pgid, "KILL", **signal_options)
89
+ false
90
+ end
91
+
92
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
+
94
+ # Reads a pipe in chunks on a thread of its own, so the output read so
95
+ # far survives the pipe being closed under it (or the thread killed).
96
+ class PipeReader
97
+ # @param cap [Integer, nil] the most bytes it keeps
98
+ # @param keep [:head, :tail] past the cap: :head stops reading (and
99
+ # #over? is true), :tail keeps the last +cap+ bytes
100
+ def initialize(io, cap: nil, keep: :head, chunk: READ_CHUNK_BYTES)
101
+ @io = io
102
+ @buffer = String.new(encoding: Encoding::BINARY)
103
+ @over = false
104
+ @thread = Thread.new do
105
+ Thread.current.report_on_exception = false
106
+ loop { break unless take(io.readpartial(chunk), cap, keep) }
107
+ rescue IOError # EOFError is one
108
+ nil
109
+ end
110
+ end
111
+
112
+ def done? = !@thread.alive?
113
+
114
+ # Whether a :head reader stopped at its cap.
115
+ def over? = @over
116
+
117
+ # @return [Thread, nil] the thread when it ended within +seconds+
118
+ def wait(seconds) = @thread.join(seconds)
119
+
120
+ def kill = @thread.kill
121
+
122
+ # Closes the pipe once the thread had +poll+ to end by itself (a
123
+ # reader still blocked gets IOError), then ends the thread, killing it
124
+ # after +grace+. The text is safe to read afterwards.
125
+ def finish(poll:, grace:)
126
+ @thread.join(poll) unless done?
127
+ close_quietly
128
+ @thread.kill unless @thread.join(grace)
129
+ end
130
+
131
+ # What it read, in +encoding+ (default: the pipe's).
132
+ def text(encoding = @io.external_encoding || Encoding.default_external)
133
+ @buffer.dup.force_encoding(encoding)
134
+ end
135
+
136
+ private
137
+
138
+ # @return [Boolean] whether to read on
139
+ def take(chunk, cap, keep)
140
+ if cap && keep == :head && @buffer.bytesize + chunk.bytesize > cap
141
+ @over = true
142
+ return false
143
+ end
144
+
145
+ @buffer << chunk
146
+ @buffer = @buffer.byteslice(-cap, cap) if cap && keep == :tail && @buffer.bytesize > cap
147
+ true
148
+ end
149
+
150
+ def close_quietly
151
+ @io.close unless @io.closed?
152
+ rescue IOError
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end
@@ -32,6 +32,8 @@ module Samagotchi
32
32
  # Seconds #take_pin waits for the busy-slot check (Client#slot_status
33
33
  # gives up within 3 s).
34
34
  DECIDE_TIMEOUT = 3
35
+ # Seconds #stop waits for a killed warm-up's thread to end.
36
+ STOP_JOIN_SECONDS = 1
35
37
 
36
38
  # Whether cache.warmup allows a warm-up (auto, the default; off, false,
37
39
  # no or 0 turn it off; anything else warns once and is auto).
@@ -54,13 +56,16 @@ module Samagotchi
54
56
  # The pin (#take_pin forgets it) and the last warm-up's thread.
55
57
  @state = nil
56
58
  @thread = nil
59
+ @stopped = false
57
60
  end
58
61
 
59
62
  # Start a warm-up of +prompt+ on +client+ in its own thread.
60
63
  # @param slot [Integer, nil] the last request's slot (nil: unpinned, and
61
64
  # nothing to pin the next request to)
62
- # @return [Thread]
65
+ # @return [Thread, nil] nil once #stop ran
63
66
  def start(client:, prompt:, model:, slot:, images: [])
67
+ return nil if @mutex.synchronize { @stopped }
68
+
64
69
  # Whether the warm-up went to its slot (true) or was skipped: the
65
70
  # first value pushed is the answer.
66
71
  sent = Queue.new
@@ -111,6 +116,20 @@ module Samagotchi
111
116
  thread&.alive? || false
112
117
  end
113
118
 
119
+ # Ends a warm-up still running and starts none after (Engine#shutdown:
120
+ # no next turn will use it). Its request is cut, which frees the slot.
121
+ def stop(timeout = STOP_JOIN_SECONDS)
122
+ thread = @mutex.synchronize do
123
+ @stopped = true
124
+ @state = nil
125
+ @thread
126
+ end
127
+ return unless thread&.alive?
128
+
129
+ thread.kill
130
+ thread.join(timeout)
131
+ end
132
+
114
133
  # Wait up to +timeout+ seconds for the running warm-up (specs).
115
134
  def wait(timeout = nil)
116
135
  thread = @mutex.synchronize { @thread }
@@ -5,6 +5,7 @@ require "monitor"
5
5
  require "securerandom"
6
6
  require "time"
7
7
 
8
+ require_relative "client_id"
8
9
  require_relative "log"
9
10
  require_relative "tools/ask_user_question"
10
11
  require_relative "guardrails/parent_approvals"
@@ -266,7 +267,7 @@ module Samagotchi
266
267
  sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
267
268
  fm = freeform.to_s.strip
268
269
  fm = nil if fm.empty?
269
- parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
270
+ parent_agent = client_id.to_s == ClientId::CLI_ANSWER if parent_agent.nil?
270
271
  # Read before the lock (config may touch the disk); this worker's own.
271
272
  parent_setting = Guardrails::ParentApprovals.setting if parent_agent
272
273
  parent_continue = Guardrails::ParentContinue.allowed? if parent_agent
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
3
4
  require_relative "session"
4
5
  require_relative "bridge_client"
5
6
  require_relative "worker_sidecar"
@@ -15,9 +16,6 @@ module Samagotchi
15
16
  # answers its own question with that, only when it is about this session
16
17
  # and the question pending now, and is answered.
17
18
  class RelayVerifier
18
- # The client id a relayed answer is recorded with: relay:<parent8>.
19
- CLIENT_PREFIX = "relay"
20
-
21
19
  # @param engine [Engine] this (the child's) worker's Engine
22
20
  def initialize(engine:, state_dir:, session_id:)
23
21
  @engine = engine
@@ -44,7 +42,7 @@ module Samagotchi
44
42
  def apply(relay, parent_id:, question_id:, relay_id:)
45
43
  answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
46
44
  parent_agent = relay["by"] != "user"
47
- client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
45
+ client_id = "#{ClientId::RELAY_PREFIX}#{parent_id[0, 8]}"
48
46
  if answer["dismissed"]
49
47
  return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
50
48
 
@@ -62,8 +62,9 @@ module Samagotchi
62
62
  # question already pending then is not the answer's. A nil
63
63
  # messages: skips the count. last_turn: (the session's
64
64
  # last_turn["ended_at"] then, maybe nil) ends the wait on any turn
65
- # that ended since, idle again, however fast it ran: a failure after
66
- # a failure replaces the note and leaves the count as it was.
65
+ # that ended since, idle again, however fast it ran (a failure after
66
+ # a failure replaces the note and leaves the count as it was), and
67
+ # with it the count isn't used: a note between turns grows it too.
67
68
  # @param owner_grace [Numeric, nil] seconds with no live worker before
68
69
  # :worker_gone (one that died before its rescue leaves it running)
69
70
  # @param interject [#call, nil] called once a poll, before the sleep
@@ -145,13 +146,17 @@ module Samagotchi
145
146
  end
146
147
 
147
148
  # An idle session whose turn ended after the baseline was taken: its
148
- # last_turn moved on, or its messages grew (a turn's note).
149
+ # last_turn moved on. Every turn's end records last_turn as it goes
150
+ # idle (Engine#end_turn), so with one in the baseline that alone
151
+ # decides: messages also grow between turns (a new worker's context
152
+ # note, a chi note) with no turn run. A baseline without it falls back
153
+ # to the count (a turn's note).
149
154
  def turn_ended_since?(session, baseline)
150
155
  return false unless baseline
151
156
 
152
157
  if baseline.key?(:last_turn)
153
158
  ended = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
154
- return true if ended && ended != baseline[:last_turn]
159
+ return !ended.nil? && ended != baseline[:last_turn]
155
160
  end
156
161
  !!(baseline[:messages] && session.messages.size > baseline[:messages])
157
162
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
3
4
  require_relative "session"
4
5
  require_relative "session_inbox"
5
6
  require_relative "session_manager"
@@ -26,7 +27,7 @@ module Samagotchi
26
27
  include CLI::Command
27
28
  include CLI::ParentWait
28
29
 
29
- CLIENT_ID = "cli:send"
30
+ CLIENT_ID = ClientId::CLI_SEND
30
31
  # The cap on one turn's images the Bridge checks.
31
32
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
32
33
 
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
+ require_relative "client_id"
8
9
  require_relative "atomic_file"
9
10
  require_relative "config"
10
11
 
@@ -59,9 +60,6 @@ module Samagotchi
59
60
  autoload :SessionRetention, File.expand_path("session_retention", __dir__)
60
61
 
61
62
  class SessionManager
62
- # Origin of the synthetic turn queued when reminders are due.
63
- REMINDER_CLIENT_ID = "system:reminder"
64
-
65
63
  # Raised when the interactive TUI owns the session: it runs its own Engine
66
64
  # and reads no input files, so a worker must not be spawned or signalled.
67
65
  class OwnedByTUI < StandardError
@@ -583,7 +581,7 @@ module Samagotchi
583
581
  # @raise [OwnedByTUI] a chi REPL owns the session
584
582
  # @raise [RestartRefused]
585
583
  # @raise [ArgumentError] no such session
586
- def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: "cli:restart")
584
+ def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: ClientId::CLI_RESTART)
587
585
  sd = state_dir || Session.default_state_dir
588
586
  Session.load(session_id, state_dir: sd) # ArgumentError for none
589
587
  owner = refuse_tui!(session_id, state_dir: sd)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "client_id"
4
+
3
5
  module Samagotchi
4
6
  # A plugin's steer: text a plugin puts into the running turn (Engine#steer),
5
7
  # the way a UI's steering does. The drain a loop gets returns items: a
@@ -12,8 +14,9 @@ module Samagotchi
12
14
  # "input": they are part of that turn, not a turn of their own, so a
13
15
  # reloaded history keeps the turn in one piece. A line can be a Line
14
16
  # naming who sent it (a worker's input from chi send, a delegating parent,
15
- # a plugin); its input message then carries that source:. The user's own
16
- # lines carry none, so their saved bytes are as they always were.
17
+ # a plugin, a client chi doesn't know); its input message then carries
18
+ # that source:. The user's own lines carry none, so their saved bytes are
19
+ # as they always were.
17
20
  module Steer
18
21
  KIND = "steer"
19
22
  INPUT_KIND = "input"
@@ -27,18 +30,10 @@ module Samagotchi
27
30
  def initialize(text:, source:, mark: nil) = super
28
31
  end
29
32
 
30
- # The client ids whose input is not the user's own words. Literals, not
31
- # SendCommand::CLIENT_ID / Tools::Delegate::CLIENT_PREFIX, so this file
32
- # needs no requires (a spec pins them equal).
33
- CHI_SEND_CLIENT = "cli:send"
34
- DELEGATE_CLIENT_PREFIX = "delegate:"
35
- # A delegate child's report (ChildReports::CLIENT_PREFIX).
36
- CHILD_CLIENT_PREFIX = "child:"
37
- # A turn chi ran because an attached context source changed
38
- # (context:<name>, Worker's context wake).
39
- CONTEXT_CLIENT_PREFIX = "context:"
40
33
  DELEGATE_REPORT = "delegate_report"
41
- PLUGIN_CLIENT = "plugin"
34
+ # The source of a line from a client that is not a human's
35
+ # (ClientId.human?) and not one chi names: automatic:<client id>.
36
+ AUTOMATIC_SOURCE_PREFIX = "automatic:"
42
37
 
43
38
  # What one drain brought: the merged user text (nil when none) and how
44
39
  # many lines made it, the input messages' text grouped by sender
@@ -98,35 +93,48 @@ module Samagotchi
98
93
  "Don't call delegate_result for it; to retry or redirect that child, use delegate with " \
99
94
  "its session:.]"
100
95
  }.freeze
96
+ # A line from a client chi doesn't know (AUTOMATIC_SOURCE_PREFIX):
97
+ # never taken for the user's.
98
+ AUTOMATIC_HEADER = "[Automatic input from %s, sent mid-task; not your user's message. " \
99
+ "If it asks for nothing, carry on with the task.]"
101
100
 
102
101
  module_function
103
102
 
104
103
  # The saved source of a worker input line from +client_id+: nil for the
105
- # user (no client id, the web, an attached TUI, anything unknown).
104
+ # user (no client id, the web, an attached TUI); automatic:<client id>
105
+ # for a client that is no human's and chi doesn't name (an unknown id is
106
+ # not the user).
106
107
  def source_for_client(client_id)
107
108
  id = client_id.to_s
108
- if id == CHI_SEND_CLIENT then "chi_send"
109
- elsif id.start_with?(DELEGATE_CLIENT_PREFIX) then "parent_agent"
110
- elsif id.start_with?(CHILD_CLIENT_PREFIX) then DELEGATE_REPORT
111
- elsif id == PLUGIN_CLIENT then "plugin_send"
109
+ if id == ClientId::CLI_SEND then "chi_send"
110
+ elsif id.start_with?(ClientId::DELEGATE_PREFIX) then "parent_agent"
111
+ elsif id.start_with?(ClientId::CHILD_PREFIX) then DELEGATE_REPORT
112
+ elsif id == ClientId::PLUGIN then "plugin_send"
113
+ elsif !ClientId.human?(client_id) then "#{AUTOMATIC_SOURCE_PREFIX}#{id}"
112
114
  end
113
115
  end
114
116
 
115
117
  # The header for a steer or merged input message, else nil. No source
116
118
  # (or "") is the user's; an input with an unknown source too; a steer
117
- # with an unknown source is a plugin's (its bundle or hook label).
119
+ # with an unknown source is a plugin's (its bundle or hook label); an
120
+ # automatic:<client id> source names that client, not the user.
118
121
  def header(message)
119
122
  kind = (message[:kind] || message["kind"]).to_s
120
123
  return unless [KIND, INPUT_KIND].include?(kind)
121
124
 
122
125
  source = (message[:source] || message["source"]).to_s
123
126
  return HEADERS[source] if HEADERS.key?(source)
127
+ return automatic_header(source.delete_prefix(AUTOMATIC_SOURCE_PREFIX)) if source.start_with?(AUTOMATIC_SOURCE_PREFIX)
124
128
 
125
129
  source = "user" if source.empty?
126
130
  sender = SENDERS[source] || (kind == KIND ? "from the #{source} plugin" : SENDERS["user"])
127
131
  "[Steer #{sender}, #{HEADER_TAIL}"
128
132
  end
129
133
 
134
+ def automatic_header(client_id)
135
+ format(AUTOMATIC_HEADER, client_id.empty? ? "an unnamed client" : client_id)
136
+ end
137
+
130
138
  # The text a steer or merged input goes out as: its header, a newline,
131
139
  # then +text+ (the message's content by default). Other messages: +text+.
132
140
  def wire_text(message, text = (message[:content] || message["content"]).to_s)
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+ require_relative "steer"
6
+ require_relative "generation_phase"
7
+ require_relative "waiting_steer"
8
+
9
+ module Samagotchi
10
+ # A message for the running turn cutting the generation it came during:
11
+ # what the generation streams (GenerationPhase, fed by #observe from the
12
+ # turn's stream) and a message that came too early to cut (WaitingSteer).
13
+ # The Engine's seam: Bridge and TerminalUI call Engine#cut_for_steer and
14
+ # #input_epoch, the turn thread feeds #observe and #delivered!, and the
15
+ # turn's end calls #finished!. Callable from any thread: GenerationPhase
16
+ # and WaitingSteer hold their own locks.
17
+ class SteerCut
18
+ # A chunk's thinking and text. A chunk without the lanes (no loop of
19
+ # ours sends one) counts as text.
20
+ def self.lanes(event)
21
+ text = event.key?(:text) ? event[:text] : event[:content]
22
+ { thinking: event[:thinking].to_s, text: text.to_s }
23
+ end
24
+
25
+ # +clock+: monotonic seconds; +controller+: the running turn's cancel
26
+ # controller (nil between turns), read only when a cut is due.
27
+ def initialize(clock:, controller:)
28
+ # What the running generation streams: whether a steer may cut it.
29
+ @phase = GenerationPhase.new(clock: clock)
30
+ # A message that may cut it but came too early: it cuts later.
31
+ @waiting = WaitingSteer.new
32
+ @controller = controller
33
+ end
34
+
35
+ # A message for the running turn from +source+ (Steer.source_for_client:
36
+ # nil for the user, "chi_send", "parent_agent") cuts the streaming
37
+ # generation when it has streamed only thinking for steer.cut_after
38
+ # seconds (GenerationPhase): the loops' cut path (CutPolicy) then starts
39
+ # the step again with the message, no nudge, no retry spent. Too early,
40
+ # the message waits (WaitingSteer) and cuts once the thinking passes it
41
+ # (#recheck, on each thinking chunk), unless a boundary hands it to the
42
+ # model first. A plugin's message never cuts, nor an unknown client's
43
+ # (automatic:<id>).
44
+ # Call it after the message is queued, with the #input_epoch read
45
+ # before queueing it. Outside any lock; a cut that lands just after the
46
+ # generation ended is harmless (the loop re-asks).
47
+ # @return [Boolean] whether a generation was cut now
48
+ def cut_for_steer(source, epoch: nil)
49
+ return false unless Steer.cuts?(source)
50
+
51
+ after = Config.get("steer.cut_after").to_i
52
+ return false unless after.positive?
53
+
54
+ ctrl = @controller.call
55
+ return false unless ctrl
56
+ return cut!(ctrl, source.to_s) if @phase.cuttable?(after)
57
+
58
+ Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting.wait!(source, epoch: epoch)
59
+ false
60
+ rescue StandardError
61
+ false
62
+ end
63
+
64
+ # The drains that took input so far (WaitingSteer#epoch): read it before
65
+ # queueing a message for #cut_for_steer.
66
+ def input_epoch
67
+ @waiting.epoch
68
+ end
69
+
70
+ # The turn's stream: the generation's phase follows it, and a thinking
71
+ # chunk rechecks a waiting message. +progress+: a chunk's lanes
72
+ # (.lanes), read from it when not given.
73
+ def observe(event, progress = nil)
74
+ case event[:type]
75
+ when :generation_started then @phase.started!
76
+ when :generation_chunk
77
+ progress ||= self.class.lanes(event)
78
+ @phase.chunk!(**progress, tool_call: event[:tool_call])
79
+ recheck unless progress[:thinking].empty?
80
+ when :generation_retrying then @phase.retrying!
81
+ when :generation_completed, :generation_cancelled then @phase.finished!
82
+ end
83
+ end
84
+
85
+ # The turn's input went to the model: a message that waited to cut is in.
86
+ def delivered!
87
+ @waiting.delivered!
88
+ end
89
+
90
+ # The turn ended: no generation, nothing waiting.
91
+ def finished!
92
+ @phase.finished!
93
+ @waiting.clear!
94
+ end
95
+
96
+ private
97
+
98
+ # A thinking chunk streamed: a waiting message cuts once the thinking
99
+ # passes steer.cut_after.
100
+ def recheck
101
+ return unless @waiting.waiting?
102
+
103
+ after = Config.get("steer.cut_after").to_i
104
+ return unless after.positive? && @phase.cuttable?(after)
105
+
106
+ source = @waiting.take
107
+ ctrl = @controller.call
108
+ cut!(ctrl, source, waited: true) if source && ctrl
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ def cut!(ctrl, source, waited: false)
114
+ age = @phase.age
115
+ cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
116
+ if cut
117
+ @waiting.clear!
118
+ fields = { source: source, age: age&.round(1) }
119
+ fields[:waited] = true if waited
120
+ Log.info(:turn, "steer_cut", **fields)
121
+ end
122
+ cut
123
+ end
124
+ end
125
+ end
@@ -195,7 +195,7 @@ module Samagotchi
195
195
  6. Use write for full-file rewrites or creating new files.
196
196
 
197
197
  Memory convention:
198
- Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder:" line below)
198
+ Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder" line below)
199
199
  System scope: ~/.config/samagotchi/memories/ (cross-project)
200
200
  memory_read accepts optional scope (project|system).
201
201
  memory_write requires explicit scope and entry name.
@@ -413,21 +413,33 @@ module Samagotchi
413
413
  .find { |path| File.file?(path) }
414
414
  end
415
415
 
416
- # Where the session runs and which project memory folder it uses. The root
417
- # line appears only when it differs from the cwd (a worktree or subdir).
416
+ # Where the session runs and which project memory folder it uses. Only
417
+ # this checkout is named: its top (at its root too, so a model doesn't
418
+ # take the memories folder for the project and cd there, Qwen
419
+ # 2026-10-06), and in a linked worktree a line that work stays in it. The repository's main
420
+ # checkout (MemoryPaths.project_root, which scopes memories and session
421
+ # lists) is never shown: a model told its path went to work there
422
+ # (a worktree child committed onto main, 2026-10-03 and 2026-10-06).
418
423
  # The home directory is spelled out once so the model copies the right
419
424
  # sequence, with the advice to write it as ~ or $HOME instead.
420
425
  def project_location
421
426
  cwd = Dir.pwd
422
- root = MemoryPaths.project_root(cwd)
427
+ top = MemoryPaths.work_tree_root(cwd)
423
428
  lines = ["Current working directory:", cwd]
424
- unless root == cwd
425
- lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
426
- lines << root
429
+ if top && top != cwd
430
+ lines << "Top of this checkout (where the project's commands run from; stay inside it):"
431
+ lines << top
432
+ elsif top
433
+ lines << "This checkout (the project's files; its commands run here):"
434
+ lines << top
435
+ end
436
+ if top && MemoryPaths.project_root(cwd) != top
437
+ lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
438
+ "repository's other checkouts."
427
439
  end
428
440
  home = Dir.home
429
441
  lines << "Home directory: #{home} (write it as ~ or $HOME in commands and paths)" unless home.to_s.empty?
430
- lines << "Project memories folder:"
442
+ lines << "Project memories folder (your notes about the project, not its files; never work or run commands there):"
431
443
  lines << home_relative(Tools::MemoryRead.memories_dir("project"))
432
444
  lines.join("\n")
433
445
  rescue StandardError
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../client_id"
3
4
  require_relative "event_renderer"
4
5
  require_relative "formatting"
5
6
  require_relative "../prompt_history"
@@ -773,7 +774,7 @@ module Samagotchi
773
774
  return @screen.commit(answer.error)
774
775
  end
775
776
 
776
- parent = @parent_answers ? { client_id: Guardrails::ParentApprovals::CLIENT_ID } : {}
777
+ parent = @parent_answers ? { client_id: ClientId::CLI_ANSWER } : {}
777
778
  reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
778
779
  case reply.status
779
780
  when 200
@@ -1268,9 +1269,9 @@ module Samagotchi
1268
1269
  nil
1269
1270
  end
1270
1271
 
1271
- def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
1272
- def report_origin?(origin) = origin[:client_id].to_s.start_with?(Steer::CHILD_CLIENT_PREFIX)
1273
- def context_origin?(origin) = origin[:client_id].to_s.start_with?(Steer::CONTEXT_CLIENT_PREFIX)
1272
+ def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1273
+ def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
1274
+ def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
1274
1275
  # A turn with no prompt: "(continuing)"; a context wake's label alone.
1275
1276
  def continuing_text(origin) = context_origin?(origin) ? nil : "(continuing)"
1276
1277
  end