samagotchi 0.19.0 → 0.21.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 +130 -1
- data/docs/architecture.md +8 -1
- data/docs/cli.md +16 -4
- data/docs/configuration.md +4 -1
- data/docs/guardrails.md +92 -14
- data/docs/plugins.md +27 -8
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +28 -4
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +31 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +2 -1
- data/lib/samagotchi/guardrails/approval.rb +4 -3
- data/lib/samagotchi/guardrails/approvals.rb +11 -5
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/memory_bundle/builder.rb +1 -5
- data/lib/samagotchi/memory_bundle/installer.rb +46 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +12 -4
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +230 -23
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -0
- data/lib/samagotchi/web/public/app.js +68 -22
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/format.js +11 -0
- data/lib/samagotchi/web/public/index.html +33 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/timing.js +11 -5
- data/lib/samagotchi/web/public/turn_events.js +17 -1
- data/lib/samagotchi/web/session_summary.rb +6 -2
- metadata +5 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rbconfig"
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "model_profile"
|
|
6
|
+
require_relative "host_registry"
|
|
6
7
|
require_relative "bootstrap/probe"
|
|
7
8
|
require_relative "bootstrap/config_writer"
|
|
8
9
|
require_relative "bootstrap/bundles"
|
|
@@ -235,9 +236,11 @@ module Samagotchi
|
|
|
235
236
|
|
|
236
237
|
def test(result, model, key_env)
|
|
237
238
|
done = Queue.new
|
|
238
|
-
waiting =
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
waiting = if loading_hint?(result)
|
|
240
|
+
Thread.new do
|
|
241
|
+
@stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
|
|
242
|
+
end
|
|
243
|
+
end
|
|
241
244
|
seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
|
|
242
245
|
@stdout.puts(format("test: answered in %.1f s", seconds))
|
|
243
246
|
true
|
|
@@ -249,17 +252,25 @@ module Samagotchi
|
|
|
249
252
|
waiting&.join
|
|
250
253
|
end
|
|
251
254
|
|
|
255
|
+
# The "loading the model?" hint fits a local server (a llama.cpp loading
|
|
256
|
+
# weights); a remote provider's slow answer is the network, not a load.
|
|
257
|
+
def loading_hint?(result)
|
|
258
|
+
!HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
|
|
259
|
+
end
|
|
260
|
+
|
|
252
261
|
# 0 written, 1 written but not as planned, nil with @exit on a refusal.
|
|
253
262
|
def write(result, model, key_env, options)
|
|
254
263
|
writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
|
|
255
264
|
fields = host_fields(result.candidate, result.native?, key_env)
|
|
256
265
|
ids = result.models.map(&:id)
|
|
257
|
-
|
|
258
|
-
|
|
266
|
+
base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
|
|
267
|
+
name, reason = begin
|
|
268
|
+
writer.host_name_with_reason(base, requested: options[:name], model_ids: ids)
|
|
259
269
|
rescue Bootstrap::ConfigWriter::Error => e
|
|
260
270
|
@exit = usage_error(e.message)
|
|
261
271
|
return nil
|
|
262
272
|
end
|
|
273
|
+
explain_name(base, name, reason)
|
|
263
274
|
outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
|
|
264
275
|
code = report(outcome, model)
|
|
265
276
|
return code unless INSTALLS_BUNDLES.include?(outcome.kind)
|
|
@@ -269,6 +280,16 @@ module Samagotchi
|
|
|
269
280
|
code
|
|
270
281
|
end
|
|
271
282
|
|
|
283
|
+
# One line when the name wasn't free as derived: why chi picked another.
|
|
284
|
+
def explain_name(base, name, reason)
|
|
285
|
+
case reason
|
|
286
|
+
when :taken
|
|
287
|
+
@stdout.puts("a host named #{base} already exists; saved as #{name}, use --name to choose")
|
|
288
|
+
when :model_prefix
|
|
289
|
+
@stdout.puts("a model id starts with #{base} (chi would read it as a host); saved as #{name}, use --name to choose")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
272
293
|
# The system bundle, core, and dev when a terminal says yes: one line
|
|
273
294
|
# each. 1 when something failed.
|
|
274
295
|
def install_bundles(dry_run: false)
|
|
@@ -39,6 +39,8 @@ module Samagotchi
|
|
|
39
39
|
class SSEWriter
|
|
40
40
|
DEFAULT_MAX_QUEUE = 1024
|
|
41
41
|
DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
|
42
|
+
# Queued by the hang-up watcher (#watch_hangup) to wake #drain!.
|
|
43
|
+
HANGUP = Object.new.freeze
|
|
42
44
|
|
|
43
45
|
# @param engine [Samagotchi::Engine] the owning engine (live fan-out)
|
|
44
46
|
# @param ring [Samagotchi::Bridge::RingBuffer] shared capture buffer
|
|
@@ -77,6 +79,7 @@ module Samagotchi
|
|
|
77
79
|
@foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
|
|
78
80
|
@high_water = @foreign_cursor ? 0 : cursor_seq
|
|
79
81
|
@handle = nil
|
|
82
|
+
@hung_up = false
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
# Non-blocking enqueue used as the per-connection live observer. Any
|
|
@@ -97,6 +100,7 @@ module Samagotchi
|
|
|
97
100
|
# the client disconnects or the bridge stops.
|
|
98
101
|
def serve!(io)
|
|
99
102
|
write_sse_headers(io)
|
|
103
|
+
watcher = watch_hangup(io)
|
|
100
104
|
begin
|
|
101
105
|
if @join_with_snapshot
|
|
102
106
|
# Subscribe and snapshot as one step of the event log, then go live.
|
|
@@ -119,11 +123,36 @@ module Samagotchi
|
|
|
119
123
|
# Client hung up; nothing to do.
|
|
120
124
|
ensure
|
|
121
125
|
@engine.unsubscribe(handle: @handle) if @handle
|
|
126
|
+
watcher&.kill
|
|
122
127
|
end
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
private
|
|
126
131
|
|
|
132
|
+
# A client that hangs up is noticed now, not at the next write (a
|
|
133
|
+
# heartbeat, up to its interval away): the Bridge stops counting its
|
|
134
|
+
# stream (Bridge#open_streams_except), so an attached /exit from the
|
|
135
|
+
# TUI attached after it isn't held by it. A thread waits for the
|
|
136
|
+
# socket to read EOF and wakes #drain!. A client sends nothing after
|
|
137
|
+
# its request; bytes it does send are dropped. Real sockets only.
|
|
138
|
+
def watch_hangup(io)
|
|
139
|
+
return nil unless io.is_a?(IO)
|
|
140
|
+
|
|
141
|
+
Thread.new do
|
|
142
|
+
loop do
|
|
143
|
+
case io.read_nonblock(4096, exception: false)
|
|
144
|
+
when nil then break
|
|
145
|
+
when :wait_readable then io.wait_readable
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
rescue IOError, SystemCallError
|
|
149
|
+
nil
|
|
150
|
+
ensure
|
|
151
|
+
@hung_up = true
|
|
152
|
+
@queue.push(HANGUP)
|
|
153
|
+
end.tap { |thread| thread.report_on_exception = false }
|
|
154
|
+
end
|
|
155
|
+
|
|
127
156
|
def replay!(io, snapshot_seq)
|
|
128
157
|
from_seq = @high_water
|
|
129
158
|
oldest = @ring.oldest_seq
|
|
@@ -146,6 +175,9 @@ module Samagotchi
|
|
|
146
175
|
def drain!(io)
|
|
147
176
|
loop do
|
|
148
177
|
payload = @queue.pop(@heartbeat_interval)
|
|
178
|
+
# The watcher's flag, not only its HANGUP: an overflow can drop that.
|
|
179
|
+
break if @hung_up
|
|
180
|
+
|
|
149
181
|
if payload
|
|
150
182
|
if payload.is_a?(Hash) && payload[:sse_reset]
|
|
151
183
|
emit_reset(io)
|
|
@@ -38,8 +38,8 @@ module Samagotchi
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# @return [Hash, nil] a copy of the turn in progress: prompt, origin,
|
|
41
|
-
# continue, ordered parts (thinking / text / tool /
|
|
42
|
-
# reminder / notice), pending_question and the last event_seq folded
|
|
41
|
+
# continue, ordered parts (generation / thinking / text / tool /
|
|
42
|
+
# input / steer / reminder / notice), pending_question and the last event_seq folded
|
|
43
43
|
# in. A finished tool part keeps the live row's action and duration_ms
|
|
44
44
|
# too, a running one the time it has run so far (elapsed_ms), so a
|
|
45
45
|
# join times its row from its start. A notice part holds the event
|
|
@@ -101,7 +101,9 @@ module Samagotchi
|
|
|
101
101
|
# The turn in progress (#current_turn's shape, symbol keys) as the live
|
|
102
102
|
# events that would have drawn it, in order, as turn_events.js
|
|
103
103
|
# snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
|
|
104
|
-
# pins both): a
|
|
104
|
+
# pins both): a generation_started per generation part (a join during
|
|
105
|
+
# a hold shows the step the model is on), a generation_chunk per
|
|
106
|
+
# thinking or text part (a
|
|
105
107
|
# generation_completed closes text before the next step's), a tool's
|
|
106
108
|
# tool_call_started and, once finished, its tool_call_completed (with
|
|
107
109
|
# the live activity: action, tool, params, status), a merged_input
|
|
@@ -123,6 +125,9 @@ module Samagotchi
|
|
|
123
125
|
end
|
|
124
126
|
Array(turn[:parts]).each do |part|
|
|
125
127
|
case part[:kind]
|
|
128
|
+
when "generation"
|
|
129
|
+
close_text.call
|
|
130
|
+
events << { type: :generation_started, iteration: part[:iteration] }
|
|
126
131
|
when "thinking" then events << { type: :generation_chunk, text: "", thinking: part[:text], iteration: part[:iteration] }
|
|
127
132
|
when "text"
|
|
128
133
|
close_text.call if !text_iteration.nil? && text_iteration != part[:iteration]
|
|
@@ -233,6 +238,8 @@ module Samagotchi
|
|
|
233
238
|
def fold_turn_event(event)
|
|
234
239
|
parts = @turn[:parts]
|
|
235
240
|
case event[:type]
|
|
241
|
+
when :generation_started
|
|
242
|
+
parts << { kind: "generation", iteration: event[:iteration] }
|
|
236
243
|
when :generation_chunk
|
|
237
244
|
if event.key?(:text)
|
|
238
245
|
# Profile-split stream: thinking and visible text separately.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -134,7 +134,8 @@ module Samagotchi
|
|
|
134
134
|
|
|
135
135
|
# Streams held by anyone but +client_id+: a client's own stream (or two,
|
|
136
136
|
# while it reconnects and the old one isn't noticed dead yet) doesn't
|
|
137
|
-
# count. A closed stream
|
|
137
|
+
# count. A closed stream stops counting once its writer reads the
|
|
138
|
+
# hang-up (SSEWriter#watch_hangup).
|
|
138
139
|
# @return [Integer]
|
|
139
140
|
def open_streams_except(client_id)
|
|
140
141
|
@mutex.synchronize { @open_streams - (client_id ? @streams_by_client[client_id] : 0) }
|
|
@@ -698,7 +699,10 @@ module Samagotchi
|
|
|
698
699
|
# event log held, so no POST /turn lands between its check and its
|
|
699
700
|
# answer; it leaves from its loop after this reply.
|
|
700
701
|
# 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
|
|
701
|
-
# {status: "held", reason:} naming what keeps it up.
|
|
702
|
+
# {status: "held", reason:} naming what keeps it up. A past +deadline+
|
|
703
|
+
# (see #handle_post_turn), checked with the event log held: 408
|
|
704
|
+
# deadline_passed, the worker not asked (its client said it didn't stop).
|
|
705
|
+
# Returns [headers, status, body].
|
|
702
706
|
def handle_exit_request(session_id, body)
|
|
703
707
|
return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
|
|
704
708
|
|
|
@@ -709,7 +713,15 @@ module Samagotchi
|
|
|
709
713
|
delete = fetched(parsed, "delete") == true
|
|
710
714
|
restart = fetched(parsed, "restart") == true
|
|
711
715
|
options = restart ? { delete: delete, restart: true } : { delete: delete }
|
|
712
|
-
|
|
716
|
+
deadline = fetched(parsed, "deadline")
|
|
717
|
+
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
718
|
+
|
|
719
|
+
reason = @engine.synchronize_events do
|
|
720
|
+
next :expired if expired?("exit_expired", deadline, sid: session_id, client_id: client_id)
|
|
721
|
+
|
|
722
|
+
@on_exit_request.call(client_id, **options)
|
|
723
|
+
end
|
|
724
|
+
return deadline_passed("exit request") if reason == :expired
|
|
713
725
|
if reason.nil?
|
|
714
726
|
return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
|
|
715
727
|
|
|
@@ -849,7 +861,7 @@ module Samagotchi
|
|
|
849
861
|
end
|
|
850
862
|
|
|
851
863
|
# The 408 for a request read after its deadline: +what+ (turn, command,
|
|
852
|
-
# answer, dismissal) was dropped.
|
|
864
|
+
# answer, dismissal, exit request) was dropped.
|
|
853
865
|
def deadline_passed(what)
|
|
854
866
|
[{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
|
|
855
867
|
end
|
|
@@ -20,6 +20,10 @@ module Samagotchi
|
|
|
20
20
|
# A reply's status line; its code is group 1.
|
|
21
21
|
STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
|
|
22
22
|
STREAM_CONNECT_ATTEMPTS = 3
|
|
23
|
+
# Seconds #stream waits for the Bridge's response headers, per attempt:
|
|
24
|
+
# a worker that took the connection and froze must not hold the web's
|
|
25
|
+
# proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
|
|
26
|
+
STREAM_HEADER_TIMEOUT = 5.0
|
|
23
27
|
# Seconds #stream waits for bytes before it asks `running` again.
|
|
24
28
|
STREAM_POLL = 0.5
|
|
25
29
|
# Seconds a one-shot request waits for the whole reply. Every route
|
|
@@ -28,7 +32,7 @@ module Samagotchi
|
|
|
28
32
|
# raises Errno::ECONNREFUSED. The event stream has no such limit.
|
|
29
33
|
READ_TIMEOUT = 30
|
|
30
34
|
# Share of the read timeout a request's deadline allows (25 s of 30): the
|
|
31
|
-
# Bridge drops a turn, command, answer or
|
|
35
|
+
# Bridge drops a turn, command, answer, dismissal or exit it reads after the
|
|
32
36
|
# deadline, so one this client timed out on, and said did not go
|
|
33
37
|
# through, never runs when a frozen worker wakes. The rest covers the
|
|
34
38
|
# write and the reply.
|
|
@@ -159,13 +163,14 @@ module Samagotchi
|
|
|
159
163
|
# POST /session/:id/exit: ask the worker to exit now (+restart+: hand
|
|
160
164
|
# the session to a new worker, a worker with the "restart" feature). 200
|
|
161
165
|
# = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
|
|
162
|
-
# error "not_found" = a worker older than the route.
|
|
166
|
+
# error "not_found" = a worker older than the route, 408 = too late.
|
|
163
167
|
# @param client_id [String] the asking UI, whose own streams don't hold
|
|
164
168
|
# @return [Response]
|
|
165
169
|
def request_exit(client_id:, delete: false, restart: false)
|
|
166
170
|
body = { client_id: client_id }
|
|
167
171
|
body[:delete] = true if delete
|
|
168
172
|
body[:restart] = true if restart
|
|
173
|
+
body[:deadline] = deadline
|
|
169
174
|
post("exit", body, read_body: true)
|
|
170
175
|
end
|
|
171
176
|
|
|
@@ -258,7 +263,8 @@ module Samagotchi
|
|
|
258
263
|
# @param running [#call, nil] asked before every read, and every
|
|
259
264
|
# STREAM_POLL seconds of silence: the stream ends once it returns
|
|
260
265
|
# false. A quiet stream is never cut otherwise.
|
|
261
|
-
|
|
266
|
+
# @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
|
|
267
|
+
def stream(query: "", last_event_id: nil, running: nil, header_timeout: STREAM_HEADER_TIMEOUT)
|
|
262
268
|
sock = nil
|
|
263
269
|
# Absorb the probe→connect race around a resumed worker's bridge:
|
|
264
270
|
# the sidecar probe can succeed a moment before the worker dies (or
|
|
@@ -266,7 +272,7 @@ module Samagotchi
|
|
|
266
272
|
# avoid the silent empty-200 that EventSource would otherwise keep
|
|
267
273
|
# re-opening.
|
|
268
274
|
STREAM_CONNECT_ATTEMPTS.times do |attempt|
|
|
269
|
-
sock, = connect_stream(query: query, last_event_id: last_event_id)
|
|
275
|
+
sock, = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
270
276
|
break if sock
|
|
271
277
|
|
|
272
278
|
sleep(0.15 * (attempt + 1))
|
|
@@ -302,7 +308,7 @@ module Samagotchi
|
|
|
302
308
|
end
|
|
303
309
|
|
|
304
310
|
# Open GET /session/:id/stream and read past the response headers.
|
|
305
|
-
# @param timeout [Float, nil] give up when the headers
|
|
311
|
+
# @param timeout [Float, nil] give up when the headers aren't all in by then
|
|
306
312
|
# @return [Array(TCPSocket, Integer), nil] the socket positioned at the
|
|
307
313
|
# body and the HTTP status, or nil when the Bridge can't be reached
|
|
308
314
|
def connect_stream(query: "", last_event_id: nil, timeout: nil)
|
|
@@ -310,10 +316,9 @@ module Samagotchi
|
|
|
310
316
|
lei = last_event_id.to_s.strip
|
|
311
317
|
last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
|
|
312
318
|
sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
while (line = sock.gets)
|
|
319
|
+
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
320
|
+
status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
|
|
321
|
+
while (line = header_line(sock, deadline))
|
|
317
322
|
break if line.strip.empty?
|
|
318
323
|
end
|
|
319
324
|
[sock, status]
|
|
@@ -324,6 +329,25 @@ module Samagotchi
|
|
|
324
329
|
|
|
325
330
|
private
|
|
326
331
|
|
|
332
|
+
# One line of the response headers, or nil at EOF; raises
|
|
333
|
+
# Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
|
|
334
|
+
# line read in pieces, so a Bridge that stops mid-line can't hold it.
|
|
335
|
+
def header_line(sock, deadline)
|
|
336
|
+
return sock.gets unless deadline
|
|
337
|
+
|
|
338
|
+
line = +""
|
|
339
|
+
loop do
|
|
340
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
341
|
+
raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
|
|
342
|
+
|
|
343
|
+
char = sock.getc
|
|
344
|
+
return line.empty? ? nil : line if char.nil?
|
|
345
|
+
|
|
346
|
+
line << char
|
|
347
|
+
return line if char == "\n"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
327
351
|
# When this client stops waiting for a reply, as epoch seconds (see
|
|
328
352
|
# DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
|
|
329
353
|
# the same one.
|
|
@@ -200,6 +200,10 @@ module Samagotchi
|
|
|
200
200
|
end
|
|
201
201
|
|
|
202
202
|
# Runs +installer+, prints its summary, then yields the manifest.
|
|
203
|
+
# Ensures the extracted source dir is cleaned up even if an exception
|
|
204
|
+
# occurs between installer.run and the block (e.g. summary,
|
|
205
|
+
# print_hooks_and_plugin). The block (upgrade_conflicts) still sees
|
|
206
|
+
# the files because cleanup happens after the block returns or raises.
|
|
203
207
|
# @return [Integer] the block's exit status, or 1 after "<failed> failed: …"
|
|
204
208
|
def run_installer(installer, failed:)
|
|
205
209
|
_nd, manifest = installer.run
|
|
@@ -208,6 +212,8 @@ module Samagotchi
|
|
|
208
212
|
rescue Samagotchi::MemoryBundle::Installer::InstallError => e
|
|
209
213
|
@stderr.puts "#{failed} failed: #{e.message}"
|
|
210
214
|
1
|
|
215
|
+
ensure
|
|
216
|
+
installer.cleanup_source!
|
|
211
217
|
end
|
|
212
218
|
|
|
213
219
|
def print_hooks_and_plugin(manifest, plugin: true)
|
|
@@ -263,6 +269,10 @@ module Samagotchi
|
|
|
263
269
|
@stdout.puts "Provenance updated after interactive resolution."
|
|
264
270
|
@stdout.puts "Upgrade resolved interactively."
|
|
265
271
|
0
|
|
272
|
+
ensure
|
|
273
|
+
# An owned source (git/zip/tar) was kept for the incoming files above;
|
|
274
|
+
# the agent step is done with them now.
|
|
275
|
+
installer.cleanup_source!
|
|
266
276
|
end
|
|
267
277
|
|
|
268
278
|
def uninstall(rest)
|
|
@@ -353,9 +363,15 @@ module Samagotchi
|
|
|
353
363
|
@stdout.puts "Bundle '#{name}' not installed."
|
|
354
364
|
return 0
|
|
355
365
|
end
|
|
356
|
-
@stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st
|
|
357
|
-
|
|
366
|
+
@stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{status_scope(st)} installed=#{st[:provenance][:installed_at]}"
|
|
367
|
+
if st[:scope_error]
|
|
368
|
+
@stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
|
|
369
|
+
else
|
|
370
|
+
@stdout.puts "Target: #{st[:target_dir]}"
|
|
371
|
+
end
|
|
358
372
|
st[:files].each do |k, info|
|
|
373
|
+
next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
|
|
374
|
+
|
|
359
375
|
mods = []
|
|
360
376
|
mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
|
|
361
377
|
mods << "modified" if info[:modified]
|
|
@@ -409,15 +425,19 @@ module Samagotchi
|
|
|
409
425
|
plugin = st[:plugin]
|
|
410
426
|
mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
|
|
411
427
|
mods += 1 if st[:hooks_requires_failure]
|
|
428
|
+
mods += 1 if st[:scope_error]
|
|
412
429
|
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
413
430
|
includes = st[:provenance][:includes]
|
|
414
431
|
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
415
|
-
@stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st
|
|
432
|
+
@stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
416
433
|
end
|
|
417
434
|
end
|
|
418
435
|
return 0
|
|
419
436
|
end
|
|
420
437
|
|
|
438
|
+
# "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
|
|
439
|
+
def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
|
|
440
|
+
|
|
421
441
|
def diff(rest)
|
|
422
442
|
args = rest.reject { |a| a.start_with?("--") }
|
|
423
443
|
bname = args[0]
|
|
@@ -431,7 +451,11 @@ module Samagotchi
|
|
|
431
451
|
@stderr.puts "Bundle '#{bname}' not installed"; return 1
|
|
432
452
|
end
|
|
433
453
|
scope = data[:scope]&.to_s || "system"
|
|
434
|
-
|
|
454
|
+
begin
|
|
455
|
+
target_dir = Samagotchi::MemoryPaths.scope_dir!(scope)
|
|
456
|
+
rescue ArgumentError => e
|
|
457
|
+
@stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
|
|
458
|
+
end
|
|
435
459
|
files = data[:files] || {}
|
|
436
460
|
hooks = data[:hooks] || {}
|
|
437
461
|
plugin_path = prov.plugin_path(data)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-in
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:afbd0ecf5a41eefe79786e089aa918728ebb0fed0e3b75f84d754a47ec6ade18
|
|
10
10
|
requires_chi: ">= 0.3.0"
|
|
@@ -103,7 +103,11 @@ class Plugin
|
|
|
103
103
|
def check_in(ctx, count, mode)
|
|
104
104
|
case mode
|
|
105
105
|
when "nudge"
|
|
106
|
-
|
|
106
|
+
text = message(count)
|
|
107
|
+
if ctx.steer(text)
|
|
108
|
+
@mutex.synchronize { @nudge = { text: text, count: count } }
|
|
109
|
+
ctx.notify("nudged the model after #{count} tool calls")
|
|
110
|
+
end
|
|
107
111
|
when "notify"
|
|
108
112
|
ctx.notify("#{count} tool calls in this turn, no answer yet")
|
|
109
113
|
else
|
|
@@ -117,15 +121,20 @@ class Plugin
|
|
|
117
121
|
nudge_not_sent(event, ctx)
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
#
|
|
121
|
-
#
|
|
124
|
+
# A nudge that was accepted but dropped before it joined the turn (the
|
|
125
|
+
# model answered first, or the turn was stopped): the card says so, or,
|
|
126
|
+
# for a mode nudge (no card), a notice does.
|
|
122
127
|
def nudge_not_sent(event, ctx)
|
|
123
128
|
id, nudge = @mutex.synchronize { [@card_id, @nudge] }
|
|
124
|
-
return unless
|
|
129
|
+
return unless nudge
|
|
125
130
|
return if steered?(event[:messages], nudge[:text])
|
|
126
131
|
|
|
127
132
|
first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
|
|
128
|
-
|
|
133
|
+
if id
|
|
134
|
+
ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
|
|
135
|
+
else
|
|
136
|
+
ctx.notify("#{first}; nudge not sent.", level: :warn)
|
|
137
|
+
end
|
|
129
138
|
end
|
|
130
139
|
|
|
131
140
|
# Whether this turn's messages (after its last prompt) have the steer.
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Fields: id, tool (a name, a list, or shell = execute + task_create),
|
|
7
7
|
# command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
|
|
8
|
-
# a shell call runs commit/add/reset/… in another checkout),
|
|
9
|
-
#
|
|
8
|
+
# a shell call runs commit/add/reset/… in another checkout), touches
|
|
9
|
+
# (chi_dirs: a shell command names a path in chi's own dirs), skip_read_only
|
|
10
|
+
# (true: a command that only reads, ls/cat/rg/sed -n/git log…, doesn't
|
|
11
|
+
# match), modes (strict: only with guardrails.mode: strict; untagged rules
|
|
12
|
+
# ask in every mode), verdict (ask|deny), reason, scopes (for an ask: once,
|
|
13
|
+
# session, repo, rule; default all).
|
|
10
14
|
# [^;&|\n]* keeps a match inside one command of a chain; the git rules match
|
|
11
15
|
# the subcommand after optional global options (git -C dir push).
|
|
12
16
|
rules:
|
|
@@ -31,9 +35,11 @@ rules:
|
|
|
31
35
|
command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
|
|
32
36
|
verdict: ask
|
|
33
37
|
reason: git branch -D deletes a branch even if it is not merged
|
|
38
|
+
# Strict mode only: a rebase rewrites local history, which the reflog keeps.
|
|
34
39
|
- id: git-rebase
|
|
35
40
|
tool: shell
|
|
36
41
|
command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
|
|
42
|
+
modes: [strict]
|
|
37
43
|
verdict: ask
|
|
38
44
|
reason: git rebase rewrites history
|
|
39
45
|
- id: git-history-rewrite
|
|
@@ -44,10 +50,13 @@ rules:
|
|
|
44
50
|
|
|
45
51
|
# ── deleting ───────────────────────────────────────────────────────────
|
|
46
52
|
# rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
|
|
47
|
-
# (a text heuristic; rm -rf build/ in the repo is not asked)
|
|
53
|
+
# (a text heuristic; rm -rf build/ in the repo is not asked), unless
|
|
54
|
+
# every rm -rf target is inside a tmp dir (rm: outside_tmp; /tmp itself,
|
|
55
|
+
# /tmp/* and a link out of it still ask).
|
|
48
56
|
- id: rm-rf-wide
|
|
49
57
|
tool: shell
|
|
50
58
|
command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
|
|
59
|
+
rm: outside_tmp
|
|
51
60
|
verdict: ask
|
|
52
61
|
reason: rm -rf on a path outside the working tree
|
|
53
62
|
|
|
@@ -65,10 +74,13 @@ rules:
|
|
|
65
74
|
|
|
66
75
|
# ── work outside this session's repo, and git hooks ───────────────────
|
|
67
76
|
# outside_repo: not in the session's repo (its folder outside a repo);
|
|
68
|
-
# tmp dirs and memory files don't count.
|
|
77
|
+
# tmp dirs and memory files don't count. Strict mode only, as is
|
|
78
|
+
# git-outside-repo; chi's own config and hooks still ask (core protected
|
|
79
|
+
# paths, shell-touches-chi).
|
|
69
80
|
- id: write-outside-repo
|
|
70
81
|
tool: [write, edit]
|
|
71
82
|
path: outside_repo
|
|
83
|
+
modes: [strict]
|
|
72
84
|
verdict: ask
|
|
73
85
|
reason: writes outside this session's repo
|
|
74
86
|
# cd/git -C into another checkout, then commit, add, reset, checkout,
|
|
@@ -80,6 +92,7 @@ rules:
|
|
|
80
92
|
- id: git-outside-repo
|
|
81
93
|
tool: shell
|
|
82
94
|
git: outside_repo
|
|
95
|
+
modes: [strict]
|
|
83
96
|
verdict: ask
|
|
84
97
|
scopes: [once, session, rule]
|
|
85
98
|
reason: runs git that changes another checkout, outside this session's repo
|
|
@@ -90,9 +103,15 @@ rules:
|
|
|
90
103
|
reason: git hooks run code on every git command
|
|
91
104
|
|
|
92
105
|
# ── chi's own config, hooks, rules and approvals, from the shell ───────
|
|
106
|
+
# The real dirs (config.yml's dir, hooks, the approval store, installed
|
|
107
|
+
# bundles, this repo's git hooks), not a look-alike under /tmp or chi's
|
|
108
|
+
# own lib/samagotchi/hooks source; a word that can't be resolved ($VAR,
|
|
109
|
+
# $(…), a glob) is matched as text. A command that only reads them (ls,
|
|
110
|
+
# cat, rg, sed -n) isn't asked about.
|
|
93
111
|
- id: shell-touches-chi
|
|
94
112
|
tool: shell
|
|
95
|
-
|
|
113
|
+
touches: chi_dirs
|
|
114
|
+
skip_read_only: true
|
|
96
115
|
verdict: ask
|
|
97
116
|
reason: the command names chi's config, hooks, guardrails or git hooks
|
|
98
117
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config from the shell, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
|
|
8
|
-
|
|
7
|
+
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
|
|
8
|
+
# skip_read_only:, touches:, rm: and modes: need 0.20.0
|
|
9
|
+
requires_chi: ">= 0.20.0"
|
|
9
10
|
files:
|
|
10
11
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loop-guard
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:32d04fa3f4b3394e80160dd780615a4a1ddeb3be0f2e327a6497c7766dbd9f11
|
|
10
10
|
requires_chi: ">= 0.6.0"
|