samagotchi 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +25 -2
- data/docs/cli.md +41 -9
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +59 -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/client.rb +58 -10
- 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 +68 -22
- data/lib/samagotchi/guardrail_wiring.rb +56 -3
- 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/http.rb +9 -3
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/model_profile.rb +2 -2
- data/lib/samagotchi/output_formatter.rb +9 -4
- 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/served_model.rb +14 -4
- 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 +33 -8
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +75 -17
- data/lib/samagotchi/tool_declarations.rb +4 -4
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +102 -23
- 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 +9 -1
data/docs/sub-agent.md
CHANGED
|
@@ -34,9 +34,10 @@ Exit codes, both commands:
|
|
|
34
34
|
|---|---|---|
|
|
35
35
|
| 0 | `answered` | `text` is chi's reply |
|
|
36
36
|
| 0 | `command` | (`chi send`) the message was a session command (`/model x`): it ran as one, no reply comes |
|
|
37
|
+
| 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
|
|
37
38
|
| 3 | `question` | chi waits for an answer: `question`, `answer_with` |
|
|
38
39
|
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
39
|
-
| 1 | `failed`, `canceled`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened |
|
|
40
|
+
| 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it) |
|
|
40
41
|
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
41
42
|
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
42
43
|
|
|
@@ -49,8 +50,33 @@ With `--format json` stdout is one JSON object (see
|
|
|
49
50
|
"allow_freeform":false},"answer_with":"chi answer 297da360-… --question 3f1c… --option N"}
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
`kind` is `question` (the model's), `hook`,
|
|
53
|
-
command, folder, rule and reason.
|
|
53
|
+
`kind` is `question` (the model's), `hook`, `approval`, which adds the tool,
|
|
54
|
+
command, folder, rule and reason, or `continue`, the step-limit question below.
|
|
55
|
+
|
|
56
|
+
## The step limit
|
|
57
|
+
|
|
58
|
+
A turn that runs out of steps (`turn.max_iterations`, default 100) before it
|
|
59
|
+
answers waits as a question of kind `continue` (`"limit": 100`, options
|
|
60
|
+
`Continue` and `Stop`). `answer_with` continues it:
|
|
61
|
+
`chi answer ID --question QID --option Continue`, and the continued turn's
|
|
62
|
+
reply (or its next question) comes back on that same wait. To stop it:
|
|
63
|
+
`--option Stop`, with `--text "why"` for the model (Continue takes no text);
|
|
64
|
+
that wait ends with `status: not_continued`, exit 0, and the turn's work so far
|
|
65
|
+
stays in the session. A message instead (`chi send ID -m "…"`) drops the
|
|
66
|
+
question and starts a new turn. It can't be dismissed.
|
|
67
|
+
|
|
68
|
+
A parent may Continue by default: Continue grants no permission (every tool
|
|
69
|
+
call still meets the guardrails), it only spends time and tokens. With
|
|
70
|
+
`turn.parent_continue: false` in chi's config.yml (config.yml only, no
|
|
71
|
+
environment variable) a parent may only Stop: `chi answer` and the worker
|
|
72
|
+
refuse a Continue (exit 1, `a parent agent may only stop this turn here`), and
|
|
73
|
+
the user continues it from the web or the terminal.
|
|
74
|
+
|
|
75
|
+
A chi parent's `delegate_result` gets a child's step-limit question back as a
|
|
76
|
+
`question` with these commands, for its model to decide: continue it (the
|
|
77
|
+
`chi answer` command, through `execute`), send a narrower follow-up with
|
|
78
|
+
`delegate session:` (which drops the question), or report back. It isn't
|
|
79
|
+
relayed to the parent's user the way approvals are.
|
|
54
80
|
|
|
55
81
|
## Approvals
|
|
56
82
|
|
|
@@ -75,6 +101,10 @@ variable), "Allow once" goes through; the wider scopes never do,
|
|
|
75
101
|
and neither does any allow on chi's own config, hooks or guardrail rules. See
|
|
76
102
|
[Guardrails](guardrails.md#approvals-from-a-parent-agent).
|
|
77
103
|
|
|
104
|
+
Start a child in the folder it should change (`chi send --new --dir PATH`):
|
|
105
|
+
writes and mutating git outside its own repo ask (`write-outside-repo`,
|
|
106
|
+
`git-outside-repo`), which a parent can only deny.
|
|
107
|
+
|
|
78
108
|
An answer typed into `chi --attach` or the REPL counts as a parent's too when
|
|
79
109
|
stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
|
|
80
110
|
`CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
|
|
@@ -96,7 +126,11 @@ answers from any local process.
|
|
|
96
126
|
`chi answer` then exits 1 and says to send the task again
|
|
97
127
|
(`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
|
|
98
128
|
- A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
|
|
99
|
-
isn't sent: it exits 1 with the `chi answer` command for the question.
|
|
129
|
+
isn't sent: it exits 1 with the `chi answer` command for the question. The
|
|
130
|
+
step-limit question is the exception: it waits between turns, so a message
|
|
131
|
+
goes in, drops it and starts a new turn.
|
|
132
|
+
- `chi send --wait ID` with no message waits past a question already reported
|
|
133
|
+
(the step-limit question too), until someone answers it.
|
|
100
134
|
- An answer to a question that is no longer open (you answered it in the web
|
|
101
135
|
first) isn't sent; `chi answer` says so and waits for what comes next.
|
|
102
136
|
- `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
|
|
@@ -130,6 +164,10 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
|
|
|
130
164
|
`chi answer`, a piped `chi --attach` or chi's web API.
|
|
131
165
|
- `status: running` (exit 4): still working. Wait again:
|
|
132
166
|
`chi send --wait --format json --timeout 500 <session_id>`.
|
|
167
|
+
- `question.kind: continue`: chi ran out of steps before it answered. If it is
|
|
168
|
+
getting somewhere, continue it (`answer_with`, `--option Continue`); if not,
|
|
169
|
+
stop it (`--option Stop --text "why"`, exit 0, status `not_continued`) or send
|
|
170
|
+
a narrower follow-up instead. Ask your user when unsure.
|
|
133
171
|
- Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
|
|
134
172
|
shows the session.
|
|
135
173
|
|
|
@@ -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.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
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
|