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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -1
- data/README.md +4 -1
- data/docs/context.md +8 -1
- data/docs/guardrails.md +31 -1
- data/docs/hooks.md +7 -4
- data/docs/memory.md +17 -1
- data/docs/plugins.md +79 -3
- data/docs/releasing.md +3 -2
- data/docs/sessions.md +9 -3
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +1 -15
- data/lib/samagotchi/bridge.rb +8 -3
- data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
- data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
- data/lib/samagotchi/bundles/system/delegated.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/bundles/system/self_map.md +4 -2
- data/lib/samagotchi/child_reports.rb +31 -10
- data/lib/samagotchi/children_status.rb +157 -0
- data/lib/samagotchi/client_id.rb +52 -0
- data/lib/samagotchi/context_absorber.rb +2 -1
- data/lib/samagotchi/context_fetch.rb +14 -45
- data/lib/samagotchi/context_sources.rb +20 -10
- data/lib/samagotchi/cut_policy.rb +58 -0
- data/lib/samagotchi/engine.rb +24 -108
- data/lib/samagotchi/engine_peers.rb +30 -0
- data/lib/samagotchi/guardrail_wiring.rb +27 -2
- data/lib/samagotchi/guardrails/approvals.rb +7 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
- data/lib/samagotchi/guardrails/context.rb +32 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +0 -2
- data/lib/samagotchi/guardrails/shared_git.rb +124 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/kernel_loop.rb +9 -22
- data/lib/samagotchi/llm/chat_loop.rb +11 -22
- data/lib/samagotchi/memory_bundle/builder.rb +35 -1
- data/lib/samagotchi/memory_bundle/installer.rb +6 -2
- data/lib/samagotchi/memory_bundle/manifest.rb +28 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/plugin/api.rb +2 -2
- data/lib/samagotchi/plugin/sessions.rb +47 -2
- data/lib/samagotchi/process_group.rb +157 -0
- data/lib/samagotchi/prompt_warmup.rb +20 -1
- data/lib/samagotchi/question_desk.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +2 -4
- data/lib/samagotchi/reply_wait.rb +9 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/steer.rb +27 -19
- data/lib/samagotchi/steer_cut.rb +125 -0
- data/lib/samagotchi/system_prompt.rb +20 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -4
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -12
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_declarations.rb +9 -5
- data/lib/samagotchi/tools/builtins.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +105 -16
- data/lib/samagotchi/tools/delegate_wait.rb +55 -13
- data/lib/samagotchi/tools/execute.rb +10 -81
- data/lib/samagotchi/tools/task_runtime.rb +25 -38
- data/lib/samagotchi/turn_note.rb +9 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +19 -20
- data/lib/samagotchi/web/public/data.js +3 -0
- data/lib/samagotchi/web/public/question_card.js +3 -4
- data/lib/samagotchi/web/public/shown_texts.js +13 -0
- data/lib/samagotchi/web/public/stable_html.js +18 -0
- data/lib/samagotchi/web/public/turn_events.js +9 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/worker.rb +40 -13
- 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 ==
|
|
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 = "#{
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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)
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -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
|
|
16
|
-
# lines carry none, so their saved bytes are
|
|
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
|
-
|
|
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
|
|
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 ==
|
|
109
|
-
elsif id.start_with?(
|
|
110
|
-
elsif id.start_with?(
|
|
111
|
-
elsif id ==
|
|
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
|
|
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.
|
|
417
|
-
#
|
|
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
|
-
|
|
427
|
+
top = MemoryPaths.work_tree_root(cwd)
|
|
423
428
|
lines = ["Current working directory:", cwd]
|
|
424
|
-
|
|
425
|
-
lines << "
|
|
426
|
-
lines <<
|
|
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:
|
|
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?(
|
|
1272
|
-
def report_origin?(origin) = origin[:client_id].to_s.start_with?(
|
|
1273
|
-
def context_origin?(origin) = origin[:client_id].to_s.start_with?(
|
|
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
|