samagotchi 0.17.0 → 0.18.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 +64 -1
- data/docs/architecture.md +25 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +42 -14
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +11 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +8 -1
|
@@ -6,6 +6,7 @@ require_relative "bridge_client"
|
|
|
6
6
|
require_relative "reply_wait"
|
|
7
7
|
require_relative "parent_report"
|
|
8
8
|
require_relative "guardrails/parent_approvals"
|
|
9
|
+
require_relative "guardrails/parent_continue"
|
|
9
10
|
require_relative "cli/command"
|
|
10
11
|
require_relative "cli/flags"
|
|
11
12
|
require_relative "cli/parent_wait"
|
|
@@ -46,6 +47,9 @@ module Samagotchi
|
|
|
46
47
|
An approval (a guardrail's ask) can be denied here: deny it, and
|
|
47
48
|
tell your user; allowing it is the user's. With
|
|
48
49
|
guardrails.parent_approvals: once, "Allow once" is let through.
|
|
50
|
+
A step-limit question (kind continue): --option Continue, or
|
|
51
|
+
--option Stop [--text WHY] (exit 0, status not_continued); it
|
|
52
|
+
can't be dismissed.
|
|
49
53
|
Exit: 0 answered, 1 failed, refused or the worker is gone, 2
|
|
50
54
|
usage or an option the question doesn't offer, 3 a question waits,
|
|
51
55
|
4 still running (--timeout), 130 Ctrl-C.
|
|
@@ -105,8 +109,10 @@ module Samagotchi
|
|
|
105
109
|
baseline = ReplyWait.baseline_of(session, question_id: qid)
|
|
106
110
|
|
|
107
111
|
if pending && pending[:id].to_s == qid
|
|
112
|
+
return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
|
|
113
|
+
|
|
108
114
|
selected = selection(pending, options) or return @selection_status
|
|
109
|
-
refusal = approval_refusal(pending, selected, id)
|
|
115
|
+
refusal = approval_refusal(pending, selected, id) || continue_refusal(pending, selected)
|
|
110
116
|
return failure(refusal) if refusal
|
|
111
117
|
|
|
112
118
|
posted = post(id, qid, selected, options)
|
|
@@ -154,6 +160,18 @@ module Samagotchi
|
|
|
154
160
|
reason && Guardrails::ParentApprovals.message(reason)
|
|
155
161
|
end
|
|
156
162
|
|
|
163
|
+
# Why a parent may not answer Continue (turn.parent_continue: false in
|
|
164
|
+
# this process's config.yml), or nil; the worker checks it again.
|
|
165
|
+
def continue_refusal(pending, selected)
|
|
166
|
+
Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A step-limit question can't be left unanswered: the turn waits on it.
|
|
170
|
+
def not_dismissable(pending)
|
|
171
|
+
"the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(' or ')} " \
|
|
172
|
+
"(--option Stop --text WHY stops it)"
|
|
173
|
+
end
|
|
174
|
+
|
|
157
175
|
# Post the answer (or the dismissal) to the live worker's Bridge.
|
|
158
176
|
# @return [Integer, nil] an exit status when it didn't go in; nil to
|
|
159
177
|
# wait (it went in, or the question was no longer open: 409)
|
|
@@ -174,7 +192,10 @@ module Samagotchi
|
|
|
174
192
|
|
|
175
193
|
case reply.status
|
|
176
194
|
when 200 then nil
|
|
177
|
-
when 409
|
|
195
|
+
when 409
|
|
196
|
+
return failure(reply.json&.dig("detail").to_s) if reply.json&.dig("error") == "not_dismissable"
|
|
197
|
+
|
|
198
|
+
not_open(qid)
|
|
178
199
|
when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
|
|
179
200
|
# The worker's own guardrails.parent_approvals refused the allow.
|
|
180
201
|
when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context_note"
|
|
4
|
+
require_relative "output_formatter"
|
|
5
|
+
require_relative "steer"
|
|
6
|
+
require_relative "turn_note"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# "The last answer a UI shows": the newest message the web's history
|
|
10
|
+
# shows as an assistant message. One rule for the worker's Bridge (`/tail`,
|
|
11
|
+
# Engine#last_answer_message) and the web's disk path, so the two can't
|
|
12
|
+
# drift. Any change to what the web shows as an answer goes through here.
|
|
13
|
+
module AnswerTail
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param messages [Array<Hash>] a session's messages (symbol keys from
|
|
17
|
+
# disk or the Engine, string keys from a Bridge snapshot)
|
|
18
|
+
# @param turn_id [String, nil] that turn's answer: the last one between
|
|
19
|
+
# the prompt carrying this turn id and the next prompt (a queued turn's
|
|
20
|
+
# page re-reads after the next turn ran). An unknown or missing id:
|
|
21
|
+
# the newest answer.
|
|
22
|
+
# @return [Hash, nil] the raw message (not a copy), or nil
|
|
23
|
+
def find(messages, turn_id: nil)
|
|
24
|
+
list = Array(messages)
|
|
25
|
+
list = turn_slice(list, turn_id.to_s) || list unless turn_id.to_s.empty?
|
|
26
|
+
list.reverse_each { |m| return m if answer?(m) }
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The messages of the turn whose prompt carries +turn_id+, from its
|
|
31
|
+
# prompt up to the next prompt (merged input and steers stay in it), or
|
|
32
|
+
# nil when no prompt carries it.
|
|
33
|
+
def turn_slice(list, turn_id)
|
|
34
|
+
start = list.rindex { |m| Steer.turn_prompt?(m) && (m[:turn_id] || m["turn_id"]).to_s == turn_id }
|
|
35
|
+
return nil unless start
|
|
36
|
+
|
|
37
|
+
stop = list.each_index.find { |i| i > start && Steer.turn_prompt?(list[i]) } || list.size
|
|
38
|
+
list[start...stop]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Shown as an assistant message: role assistant or model, not a context
|
|
42
|
+
# note, a steer or a no-answer turn's marker, and some text left once the
|
|
43
|
+
# wire-format markup is stripped (a tool-call-only step has none).
|
|
44
|
+
def answer?(message)
|
|
45
|
+
return false unless message.is_a?(Hash)
|
|
46
|
+
return false if ContextNote.note?(message) || Steer.steer?(message) || TurnNote.empty_answer(message)
|
|
47
|
+
return false unless %w[assistant model].include?((message[:role] || message["role"]).to_s)
|
|
48
|
+
|
|
49
|
+
!OutputFormatter.strip_markup((message[:content] || message["content"]).to_s).empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -209,8 +209,11 @@ module Samagotchi
|
|
|
209
209
|
# last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
|
|
210
210
|
# what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
|
|
211
211
|
# Taken with the log held, so no event is half-applied.
|
|
212
|
-
#
|
|
213
|
-
#
|
|
212
|
+
# The pending question at the top level too: one asked between turns
|
|
213
|
+
# (the step-limit question) belongs to no turn, so the accumulator
|
|
214
|
+
# doesn't keep it.
|
|
215
|
+
# @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
|
|
216
|
+
# guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
|
|
214
217
|
def snapshot
|
|
215
218
|
@engine.synchronize_events do
|
|
216
219
|
seq = @engine.event_count
|
|
@@ -221,6 +224,7 @@ module Samagotchi
|
|
|
221
224
|
recap: @accumulator.recap,
|
|
222
225
|
saved_recap: @engine.saved_recap,
|
|
223
226
|
continue_offer: @accumulator.continue_offer,
|
|
227
|
+
pending_question: @engine.pending_question,
|
|
224
228
|
guardrail_warning: @engine.guardrail_warning,
|
|
225
229
|
plugin_warning: @engine.plugin_warning,
|
|
226
230
|
init_tasks: @engine.init_tasks,
|
|
@@ -244,12 +248,30 @@ module Samagotchi
|
|
|
244
248
|
end
|
|
245
249
|
end
|
|
246
250
|
|
|
251
|
+
# What the page re-reads at a turn's end and when a card arrives: the
|
|
252
|
+
# session state, the last answer (Engine#last_answer_message: one
|
|
253
|
+
# message, not the conversation) and the cards, as one step of the event
|
|
254
|
+
# log (one event_seq). Light whatever the session's length: GET tail.
|
|
255
|
+
# @return [Hash] {session_id:, session_state_snapshot:, answer:, cards:, event_seq:, event_id:}
|
|
256
|
+
# +turn_id+: that turn's answer, not the newest (Engine#last_answer_message).
|
|
257
|
+
def tail_frame(turn_id: nil)
|
|
258
|
+
@engine.synchronize_events do
|
|
259
|
+
seq = @engine.event_count
|
|
260
|
+
state = @engine.session_state_snapshot.merge(event_seq: seq, event_id: event_id(seq))
|
|
261
|
+
{ session_id: @session_id, session_state_snapshot: state, answer: @engine.last_answer_message(turn_id: turn_id),
|
|
262
|
+
cards: @cards.list, event_seq: seq, event_id: event_id(seq) }
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
247
266
|
# The worker's own way in for a session command that came as a message
|
|
248
267
|
# (its first prompt, an input file): queued and announced as a POST
|
|
249
268
|
# /command's is (#handle_command).
|
|
250
269
|
# @return [String] its command_id
|
|
251
|
-
def queue_command(line, client_id: nil)
|
|
270
|
+
def queue_command(line, client_id: nil, card: false)
|
|
252
271
|
command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
|
|
272
|
+
# A card's action (the step-limit question's answer): the UIs leave
|
|
273
|
+
# its line out.
|
|
274
|
+
command[:card] = true if card
|
|
253
275
|
@engine.synchronize_events { queue_command_locked(command) }
|
|
254
276
|
command[:command_id]
|
|
255
277
|
end
|
|
@@ -347,7 +369,7 @@ module Samagotchi
|
|
|
347
369
|
client_id: stream_client_id(request[:query]))
|
|
348
370
|
break # SSE owns the connection until the client disconnects.
|
|
349
371
|
else
|
|
350
|
-
payload, status, body = dispatch(route.first, route.last, request[:body])
|
|
372
|
+
payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
|
|
351
373
|
write_json(io, status, payload, body)
|
|
352
374
|
end
|
|
353
375
|
|
|
@@ -446,7 +468,8 @@ module Samagotchi
|
|
|
446
468
|
%w[POST relay/status] => :handle_relay_status,
|
|
447
469
|
%w[GET state] => :handle_state,
|
|
448
470
|
%w[GET stats] => :handle_stats,
|
|
449
|
-
%w[GET snapshot] => :handle_snapshot
|
|
471
|
+
%w[GET snapshot] => :handle_snapshot,
|
|
472
|
+
%w[GET tail] => :handle_tail
|
|
450
473
|
}.freeze
|
|
451
474
|
ROUTE_PATH = %r|\A/session/([^/]+)/(.+)\z|u
|
|
452
475
|
|
|
@@ -462,8 +485,10 @@ module Samagotchi
|
|
|
462
485
|
# #own_session?), and a handler that raises answers 500 bridge_error,
|
|
463
486
|
# logged (the connection thread doesn't report).
|
|
464
487
|
# @return [Array(Hash, Integer, Hash)] headers, status, body
|
|
465
|
-
|
|
488
|
+
# Only GET tail takes the request's +query+.
|
|
489
|
+
def dispatch(handler, session_id, body, query = nil)
|
|
466
490
|
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
491
|
+
return handle_tail(session_id, body, query) if handler == :handle_tail
|
|
467
492
|
|
|
468
493
|
send(handler, session_id, body)
|
|
469
494
|
rescue StandardError => e
|
|
@@ -533,7 +558,7 @@ module Samagotchi
|
|
|
533
558
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
534
559
|
rescue QuestionDesk::Refused => e
|
|
535
560
|
Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
|
|
536
|
-
[{}, 403, { error: "parent_approval_refused",
|
|
561
|
+
[{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
|
|
537
562
|
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
538
563
|
rescue ArgumentError => e
|
|
539
564
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
@@ -589,7 +614,12 @@ module Samagotchi
|
|
|
589
614
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
590
615
|
return deadline_passed("dismissal") if expired?("dismiss_expired", deadline, sid: session_id, id: qid)
|
|
591
616
|
|
|
592
|
-
|
|
617
|
+
begin
|
|
618
|
+
dismissed = @engine.cancel_question("dismissed", id: qid)
|
|
619
|
+
rescue QuestionDesk::NotDismissable => e
|
|
620
|
+
# A step-limit question: its answer is Continue or Stop.
|
|
621
|
+
return [{}, 409, { error: "not_dismissable", detail: e.message }]
|
|
622
|
+
end
|
|
593
623
|
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
|
|
594
624
|
|
|
595
625
|
[{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
|
|
@@ -764,6 +794,17 @@ module Samagotchi
|
|
|
764
794
|
[{}, 200, snapshot_frame]
|
|
765
795
|
end
|
|
766
796
|
|
|
797
|
+
# The page's light re-read (#tail_frame): no message list. `?turn_id=`:
|
|
798
|
+
# that turn's answer (a queued turn's page re-reads late). The one
|
|
799
|
+
# handler that gets the query (#dispatch); an older worker's route
|
|
800
|
+
# ignores it and sends the newest answer.
|
|
801
|
+
def handle_tail(session_id, _body = nil, query = nil)
|
|
802
|
+
turn_id = query && URI.decode_www_form(query).to_h["turn_id"].to_s
|
|
803
|
+
[{}, 200, tail_frame(turn_id: turn_id.to_s.empty? ? nil : turn_id)]
|
|
804
|
+
rescue ArgumentError
|
|
805
|
+
[{}, 200, tail_frame]
|
|
806
|
+
end
|
|
807
|
+
|
|
767
808
|
# The stream cursor for +seq+ in this worker.
|
|
768
809
|
def event_id(seq)
|
|
769
810
|
EventId.format(seq.to_i, @epoch)
|
|
@@ -210,17 +210,32 @@ module Samagotchi
|
|
|
210
210
|
# GET /session/:id/<path> as JSON.
|
|
211
211
|
# @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
|
|
212
212
|
def get_json(path)
|
|
213
|
+
status, body = get(path)
|
|
214
|
+
status == 200 ? body : nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# GET /session/:id/<path>, telling a refusal apart from no answer: an
|
|
218
|
+
# older worker's 404 for a route it lacks from a refused connect, a
|
|
219
|
+
# timeout or a 500.
|
|
220
|
+
# @return [Array(Integer, Object)] the status and the parsed JSON body
|
|
221
|
+
# (nil when it isn't JSON); [nil, nil] when no reply came
|
|
222
|
+
def get(path)
|
|
213
223
|
sock = TCPSocket.new(@host, @port)
|
|
214
224
|
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
|
|
215
225
|
response = read_reply(sock, path)
|
|
216
226
|
sock.close rescue nil
|
|
217
227
|
# A 500 whose text has "200" in it is not a 200.
|
|
218
|
-
|
|
228
|
+
status = response && response[STATUS_LINE, 1]
|
|
229
|
+
return [nil, nil] unless status
|
|
219
230
|
|
|
220
231
|
body = response.split("\r\n\r\n", 2)[1] || ""
|
|
221
|
-
|
|
232
|
+
[status.to_i, begin
|
|
233
|
+
JSON.parse(body)
|
|
234
|
+
rescue JSON::ParserError
|
|
235
|
+
nil
|
|
236
|
+
end]
|
|
222
237
|
rescue StandardError
|
|
223
|
-
nil
|
|
238
|
+
[nil, nil]
|
|
224
239
|
end
|
|
225
240
|
|
|
226
241
|
# Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
|
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# subcommands have their own flags).
|
|
11
11
|
class BundleCommand
|
|
12
12
|
USAGE = <<~TEXT
|
|
13
|
-
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build> [options]
|
|
13
|
+
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash> [options]
|
|
14
14
|
|
|
15
15
|
install <source> [--scope system|project] [--force]
|
|
16
16
|
Install a memory bundle from a directory, zip, tar archive, or git URL.
|
|
@@ -37,6 +37,10 @@ module Samagotchi
|
|
|
37
37
|
Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
|
|
38
38
|
--scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
|
|
39
39
|
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
|
|
40
|
+
|
|
41
|
+
trash [--empty] [--dry-run] [--older-than DAYS]
|
|
42
|
+
List bundle trash (moved files from uninstalls/upgrades).
|
|
43
|
+
--empty deletes all trash folders. --older-than DAYS keeps only recent ones.
|
|
40
44
|
TEXT
|
|
41
45
|
|
|
42
46
|
# Each subcommand's --help (and only --help: -h and help are taken as
|
|
@@ -64,6 +68,7 @@ module Samagotchi
|
|
|
64
68
|
chi bundle build --scope project --name my-bundle --version 1.0.0 --out bundle.zip
|
|
65
69
|
chi bundle build --scope system --out ./my-bundle/ identity.md work.md
|
|
66
70
|
TEXT
|
|
71
|
+
TRASH_HELP = "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
|
|
67
72
|
|
|
68
73
|
# The subcommands' flags: --help only; a word not starting with -- is
|
|
69
74
|
# a positional (install's source, -h included). --scope takes whatever
|
|
@@ -83,6 +88,11 @@ module Samagotchi
|
|
|
83
88
|
BUILD_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/, dash_values: false) do |f|
|
|
84
89
|
%w[--scope --name --version --description --out].each { |name| f.value name }
|
|
85
90
|
end
|
|
91
|
+
TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
92
|
+
f.switch "--empty"
|
|
93
|
+
f.switch "--dry-run"
|
|
94
|
+
f.value "--older-than"
|
|
95
|
+
end
|
|
86
96
|
|
|
87
97
|
# @param argv [Array<String>] the arguments after "bundle"
|
|
88
98
|
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
@@ -108,8 +118,9 @@ module Samagotchi
|
|
|
108
118
|
when "diff" then diff(@argv[1..])
|
|
109
119
|
when "list" then list
|
|
110
120
|
when "build" then build(@argv[1..])
|
|
121
|
+
when "trash" then trash(@argv[1..])
|
|
111
122
|
else
|
|
112
|
-
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
|
|
123
|
+
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash"
|
|
113
124
|
return CLI::Exit::USAGE
|
|
114
125
|
end
|
|
115
126
|
end
|
|
@@ -579,6 +590,101 @@ module Samagotchi
|
|
|
579
590
|
end
|
|
580
591
|
end
|
|
581
592
|
|
|
593
|
+
def trash(rest)
|
|
594
|
+
parsed = parse_flags(TRASH_FLAGS, rest, "trash", help: TRASH_HELP)
|
|
595
|
+
return parsed if parsed.is_a?(Integer)
|
|
596
|
+
|
|
597
|
+
opts = parsed.options
|
|
598
|
+
empty = opts.fetch(:empty, false)
|
|
599
|
+
dry_run = opts.fetch(:dry_run, false)
|
|
600
|
+
older_than = opts[:older_than]
|
|
601
|
+
|
|
602
|
+
# --older-than and --dry-run without --empty are a usage error
|
|
603
|
+
if (older_than || dry_run) && !empty
|
|
604
|
+
@stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
|
|
605
|
+
return CLI::Exit::USAGE
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Validate older_than if given
|
|
609
|
+
if older_than
|
|
610
|
+
if older_than.to_s.match?(/\A\d+\z/) && older_than.to_i > 0
|
|
611
|
+
older_than = older_than.to_i
|
|
612
|
+
else
|
|
613
|
+
@stderr.puts "Usage: chi bundle trash [--empty] [--dry-run] [--older-than DAYS]"
|
|
614
|
+
return CLI::Exit::USAGE
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
if empty
|
|
619
|
+
do_empty(older_than_days: older_than, dry_run: dry_run)
|
|
620
|
+
else
|
|
621
|
+
do_list
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def do_list
|
|
626
|
+
entries = Samagotchi::MemoryBundle::Trash.entries
|
|
627
|
+
if entries.empty?
|
|
628
|
+
@stdout.puts "The bundle trash is empty."
|
|
629
|
+
return 0
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
entries.each do |e|
|
|
633
|
+
@stdout.puts " #{e.name} #{e.files} file#{'s' unless e.files == 1} #{format_bytes(e.bytes)} #{format_age(e.time)}"
|
|
634
|
+
end
|
|
635
|
+
@stdout.puts "\nEmpty it with: chi bundle trash --empty [--older-than DAYS]"
|
|
636
|
+
0
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def do_empty(older_than_days:, dry_run:)
|
|
640
|
+
entries = Samagotchi::MemoryBundle::Trash.empty!(
|
|
641
|
+
older_than_days: older_than_days,
|
|
642
|
+
dry_run: dry_run,
|
|
643
|
+
)
|
|
644
|
+
if entries.empty?
|
|
645
|
+
@stdout.puts "The bundle trash is empty."
|
|
646
|
+
return 0
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
total_files = entries.sum(&:files)
|
|
650
|
+
if dry_run
|
|
651
|
+
entries.each do |e|
|
|
652
|
+
@stdout.puts "Would delete: #{e.name} (#{e.files} file#{'s' unless e.files == 1}, #{format_bytes(e.bytes)})"
|
|
653
|
+
end
|
|
654
|
+
@stdout.puts "\n(dry-run: #{entries.size} folder#{'s' unless entries.size == 1}, #{total_files} file#{'s' unless total_files == 1})"
|
|
655
|
+
else
|
|
656
|
+
@stdout.puts "Deleted #{entries.size} folder#{'s' unless entries.size == 1} (#{total_files} file#{'s' unless total_files == 1}) from the trash."
|
|
657
|
+
end
|
|
658
|
+
0
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Format bytes as human-readable (B, KB, MB with one decimal)
|
|
662
|
+
def format_bytes(bytes)
|
|
663
|
+
return "0 B" if bytes == 0
|
|
664
|
+
if bytes < 1024
|
|
665
|
+
"#{bytes} B"
|
|
666
|
+
elsif bytes < 1_048_576
|
|
667
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
668
|
+
else
|
|
669
|
+
"#{(bytes / 1_048_576.0).round(1)} MB"
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Format age as "just now", "N minutes ago", "N hours ago", "N days ago"
|
|
674
|
+
def format_age(time)
|
|
675
|
+
now = Time.now
|
|
676
|
+
diff = now - time
|
|
677
|
+
if diff < 60
|
|
678
|
+
"just now"
|
|
679
|
+
elsif diff < 3600
|
|
680
|
+
"#{(diff / 60).to_i} minutes ago"
|
|
681
|
+
elsif diff < 86_400
|
|
682
|
+
"#{(diff / 3600).to_i} hours ago"
|
|
683
|
+
else
|
|
684
|
+
"#{(diff / 86_400).to_i} days ago"
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
582
688
|
# +rest+ through +flags+, stopping at the first --help or error.
|
|
583
689
|
#
|
|
584
690
|
# @return [CLI::Flags::Result, Integer] the result, or the exit status
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
# earlier or `git -C` variants can get past them.
|
|
5
5
|
#
|
|
6
6
|
# Fields: id, tool (a name, a list, or shell = execute + task_create),
|
|
7
|
-
# command (Ruby regex), path (outside_repo or a glob),
|
|
7
|
+
# command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
|
|
8
|
+
# a shell call runs commit/add/reset/… in another checkout), verdict (ask|deny),
|
|
8
9
|
# reason, scopes (for an ask: once, session, repo, rule; default all).
|
|
9
10
|
# [^;&|\n]* keeps a match inside one command of a chain; the git rules match
|
|
10
11
|
# the subcommand after optional global options (git -C dir push).
|
|
@@ -62,12 +63,26 @@ rules:
|
|
|
62
63
|
verdict: ask
|
|
63
64
|
reason: runs decoded text as a script
|
|
64
65
|
|
|
65
|
-
# ──
|
|
66
|
+
# ── work outside this session's repo, and git hooks ───────────────────
|
|
67
|
+
# outside_repo: not in the session's repo (its folder outside a repo);
|
|
68
|
+
# tmp dirs and memory files don't count.
|
|
66
69
|
- id: write-outside-repo
|
|
67
70
|
tool: [write, edit]
|
|
68
71
|
path: outside_repo
|
|
69
72
|
verdict: ask
|
|
70
|
-
reason: writes outside
|
|
73
|
+
reason: writes outside this session's repo
|
|
74
|
+
# cd/git -C into another checkout, then commit, add, reset, checkout,
|
|
75
|
+
# switch, rebase, merge, push, stash, rm, mv, cherry-pick, revert, pull,
|
|
76
|
+
# restore or am. Read-only git (status, log, diff) and tests there run.
|
|
77
|
+
# "This call in this repo" would never match again (the command holds
|
|
78
|
+
# the commit message), so it isn't offered: "rule in this repo" is the
|
|
79
|
+
# always-for-here answer.
|
|
80
|
+
- id: git-outside-repo
|
|
81
|
+
tool: shell
|
|
82
|
+
git: outside_repo
|
|
83
|
+
verdict: ask
|
|
84
|
+
scopes: [once, session, rule]
|
|
85
|
+
reason: runs git that changes another checkout, outside this session's repo
|
|
71
86
|
- id: git-hooks-write
|
|
72
87
|
tool: [write, edit]
|
|
73
88
|
path: '**/.git/hooks/**'
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
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, git hooks, answering chi around chi answer), 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)
|
|
8
|
-
requires_chi: ">= 0.
|
|
7
|
+
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
|
|
8
|
+
requires_chi: ">= 0.18.0"
|
|
9
9
|
files:
|
|
10
10
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -60,7 +60,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
60
60
|
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
61
61
|
- `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
|
-
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
63
|
+
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
64
64
|
|
|
65
65
|
```yaml
|
|
66
66
|
guardrails:
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.18.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
|
|
10
10
|
memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Commands
|
|
5
7
|
# The slash (and bang) commands a session knows, in lookup order: the
|
|
@@ -74,6 +76,32 @@ module Samagotchi
|
|
|
74
76
|
|
|
75
77
|
def command?(line) = !lookup(line).nil?
|
|
76
78
|
|
|
79
|
+
# A line that is one word: a slash and a name, no spaces, no second
|
|
80
|
+
# slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
|
|
81
|
+
# are prompts).
|
|
82
|
+
UNKNOWN_COMMAND_WORD = /\A\/[A-Za-z][A-Za-z0-9_-]*\z/
|
|
83
|
+
|
|
84
|
+
# Whether +line+ is a command word no command answers: a typo like
|
|
85
|
+
# `/modle`, which a UI must not send to the model as a prompt.
|
|
86
|
+
def unknown_command_word?(line)
|
|
87
|
+
text = line.to_s.strip
|
|
88
|
+
return false unless UNKNOWN_COMMAND_WORD.match?(text)
|
|
89
|
+
|
|
90
|
+
lookup(text).nil? && lookup_local(text).nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What a UI shows for an unknown command word, or nil when +line+ is
|
|
94
|
+
# not one: `Unknown command /modle. Did you mean /model? /help lists
|
|
95
|
+
# the commands.` The "Did you mean" part needs a close name among the
|
|
96
|
+
# entries (DidYouMean::SpellChecker).
|
|
97
|
+
def unknown_command_hint(line)
|
|
98
|
+
return nil unless unknown_command_word?(line)
|
|
99
|
+
|
|
100
|
+
text = line.to_s.strip
|
|
101
|
+
close = DidYouMean::SpellChecker.new(dictionary: @entries.map(&:name)).correct(text).first
|
|
102
|
+
"Unknown command #{text}. #{close ? "Did you mean #{close}? " : ""}/help lists the commands."
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
# @return [Entry, nil] the first entry the UI runs itself (local) that
|
|
78
106
|
# matches +line+, whatever its uis (a UI answers the others' too:
|
|
79
107
|
# the REPL's /detach note)
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -142,6 +142,12 @@ module Samagotchi
|
|
|
142
142
|
# Asks again in the same turn after an empty answer (EmptyAnswerRetry);
|
|
143
143
|
# 0 = off, capped at 3. No CLI flag: workers get no CLI args.
|
|
144
144
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
145
|
+
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
146
|
+
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
147
|
+
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 100, expose: %i[env config]),
|
|
148
|
+
# Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
|
|
149
|
+
# config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
|
|
150
|
+
Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
|
|
145
151
|
# What `chi update` touches; its --no-gem/--no-bundles/--no-desktop turn one off for a run.
|
|
146
152
|
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
|
|
147
153
|
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
|
|
@@ -161,7 +167,7 @@ module Samagotchi
|
|
|
161
167
|
Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
162
168
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
163
169
|
# Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
|
|
164
|
-
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default:
|
|
170
|
+
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
165
171
|
|
|
166
172
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
167
173
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|