samagotchi 0.11.0 → 0.13.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 +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_manager"
|
|
5
|
+
require_relative "bridge_client"
|
|
6
|
+
require_relative "reply_wait"
|
|
7
|
+
require_relative "parent_report"
|
|
8
|
+
require_relative "guardrails/parent_approvals"
|
|
9
|
+
require_relative "cli/command"
|
|
10
|
+
require_relative "cli/flags"
|
|
11
|
+
require_relative "cli/parent_wait"
|
|
12
|
+
|
|
13
|
+
module Samagotchi
|
|
14
|
+
# `chi answer`: answer the question a session's worker waits on (exit 3
|
|
15
|
+
# from chi send --wait), then wait for what comes next and print it the
|
|
16
|
+
# same way: the reply (0), the next question (3), still running (4). It is
|
|
17
|
+
# how a parent agent running chi as a sub-agent answers; the web and
|
|
18
|
+
# chi --attach answer the same question. See docs/sub-agent.md.
|
|
19
|
+
#
|
|
20
|
+
# An approval is the user's to allow: a parent may deny it, and allow it
|
|
21
|
+
# only "once" when guardrails.parent_approvals (config.yml) says so. It is
|
|
22
|
+
# checked here and again by the worker, which sees the answer marked as
|
|
23
|
+
# chi answer's. That guards an honest but eager parent, not a boundary:
|
|
24
|
+
# the Bridge and the localhost web take answers from any local process.
|
|
25
|
+
class AnswerCommand
|
|
26
|
+
include CLI::Command
|
|
27
|
+
include CLI::ParentWait
|
|
28
|
+
|
|
29
|
+
USAGE = <<~TEXT
|
|
30
|
+
Usage: chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
|
|
31
|
+
chi answer ID --question QID --dismiss [--timeout S] [--format json]
|
|
32
|
+
Answers the question session ID waits on (chi send --wait exits 3
|
|
33
|
+
with it), then waits for what comes next and prints it as chi send
|
|
34
|
+
--wait does.
|
|
35
|
+
--question QID the question's id (chi send --wait prints it); a
|
|
36
|
+
question no longer open is not answered
|
|
37
|
+
--option N|LABEL
|
|
38
|
+
an option, by number (1-based) or label; repeat
|
|
39
|
+
on a multi-select question
|
|
40
|
+
--text T free text, where the question allows it; with an
|
|
41
|
+
approval, the reason for a Deny (text alone denies)
|
|
42
|
+
--dismiss leave it unanswered: chi does nothing it asked
|
|
43
|
+
about and finishes its reply
|
|
44
|
+
--timeout S give up waiting after S seconds (exit 4)
|
|
45
|
+
--format json one JSON object on stdout (see chi send --help)
|
|
46
|
+
An approval (a guardrail's ask) can be denied here: deny it, and
|
|
47
|
+
tell your user; allowing it is the user's. With
|
|
48
|
+
guardrails.parent_approvals: once, "Allow once" is let through.
|
|
49
|
+
Exit: 0 answered, 1 failed, refused or the worker is gone, 2
|
|
50
|
+
usage or an option the question doesn't offer, 3 a question waits,
|
|
51
|
+
4 still running (--timeout), 130 Ctrl-C.
|
|
52
|
+
TEXT
|
|
53
|
+
|
|
54
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
55
|
+
f.value "--question"
|
|
56
|
+
f.value "--option", key: :options, repeat: true
|
|
57
|
+
f.value "--text"
|
|
58
|
+
f.switch "--dismiss"
|
|
59
|
+
f.value "--timeout"
|
|
60
|
+
f.value "--format"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
|
|
64
|
+
@argv = argv.dup
|
|
65
|
+
@stdout = stdout
|
|
66
|
+
@stderr = stderr
|
|
67
|
+
@state_dir = state_dir || Session.default_state_dir
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def command_name = "chi answer"
|
|
73
|
+
|
|
74
|
+
def parse
|
|
75
|
+
parsed = parse_flags(FLAGS, @argv, options: [])
|
|
76
|
+
return parsed if parsed.is_a?(Integer)
|
|
77
|
+
|
|
78
|
+
options = parsed.options
|
|
79
|
+
return usage_error("give one session id") unless parsed.args.size == 1
|
|
80
|
+
return usage_error("--question QID is required (chi send --wait prints it)") if options[:question].to_s.strip.empty?
|
|
81
|
+
if options[:dismiss]
|
|
82
|
+
return usage_error("--dismiss takes no --option or --text") if !options[:options].empty? || options[:text]
|
|
83
|
+
elsif options[:options].empty? && options[:text].nil?
|
|
84
|
+
return usage_error("nothing to answer with: --option, --text or --dismiss")
|
|
85
|
+
end
|
|
86
|
+
return usage_error("--format takes text or json") if options[:format] && !FORMATS.include?(options[:format])
|
|
87
|
+
if options[:timeout]
|
|
88
|
+
options[:timeout] = Float(options[:timeout], exception: false)
|
|
89
|
+
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
90
|
+
end
|
|
91
|
+
options.merge(id: parsed.args.first)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Answer, then wait for what comes next.
|
|
95
|
+
# @return [Integer] the exit status
|
|
96
|
+
def run_parsed(options)
|
|
97
|
+
id = resolve(options[:id]) or return 1
|
|
98
|
+
qid = options[:question].strip
|
|
99
|
+
session = Session.load(id, state_dir: @state_dir)
|
|
100
|
+
pending = session.pending_question
|
|
101
|
+
# Taken before the answer goes in, with the question answered as the
|
|
102
|
+
# one already reported: the file keeps it until the turn thread
|
|
103
|
+
# wakes, and it must not come back as exit 3.
|
|
104
|
+
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
105
|
+
baseline = ReplyWait.baseline_of(session, question_id: qid)
|
|
106
|
+
|
|
107
|
+
if pending && pending[:id].to_s == qid
|
|
108
|
+
selected = selection(pending, options) or return @selection_status
|
|
109
|
+
refusal = approval_refusal(pending, selected, id)
|
|
110
|
+
return failure(refusal) if refusal
|
|
111
|
+
|
|
112
|
+
posted = post(id, qid, selected, options)
|
|
113
|
+
return posted if posted.is_a?(Integer)
|
|
114
|
+
elsif session.status != Session::STATUS_RUNNING
|
|
115
|
+
return failure("no question #{qid} waits in #{id[0, 8]}; nothing to answer")
|
|
116
|
+
else
|
|
117
|
+
not_open(qid)
|
|
118
|
+
end
|
|
119
|
+
# Answered here, or no longer open (answered elsewhere, or a newer
|
|
120
|
+
# question): what comes next either way.
|
|
121
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The labels to send. A number is an option's place (1-based); an
|
|
125
|
+
# exact label wins over a number.
|
|
126
|
+
# @return [Array<String>, nil] nil after a usage error
|
|
127
|
+
def selection(pending, options)
|
|
128
|
+
offered = Array(pending[:options]).map(&:to_s)
|
|
129
|
+
labels = options[:options].map do |given|
|
|
130
|
+
next given if offered.include?(given)
|
|
131
|
+
|
|
132
|
+
index = Integer(given, exception: false)
|
|
133
|
+
next offered[index - 1] if index&.between?(1, offered.size)
|
|
134
|
+
|
|
135
|
+
listed = offered.each_with_index.map { |label, i| "#{i + 1}. #{label}" }.join(", ")
|
|
136
|
+
@selection_status = usage_failure("no option #{given}; the options: #{listed}")
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
if labels.uniq.size > 1 && !pending[:multi_select]
|
|
140
|
+
@selection_status = usage_failure("one option only: the question is single-select")
|
|
141
|
+
return nil
|
|
142
|
+
end
|
|
143
|
+
labels.uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Why a parent may not give this answer to an approval, or nil
|
|
147
|
+
# (Guardrails::ParentApprovals, with this process's config.yml). A
|
|
148
|
+
# Deny, text alone or a dismissal always passes: each one denies. The
|
|
149
|
+
# worker checks it again with its own config.
|
|
150
|
+
def approval_refusal(pending, selected, id)
|
|
151
|
+
offered = Array(pending[:options]).map(&:to_s)
|
|
152
|
+
indices = selected.map { |label| offered.index(label) }
|
|
153
|
+
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
|
|
154
|
+
reason && Guardrails::ParentApprovals.message(reason)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Post the answer (or the dismissal) to the live worker's Bridge.
|
|
158
|
+
# @return [Integer, nil] an exit status when it didn't go in; nil to
|
|
159
|
+
# wait (it went in, or the question was no longer open: 409)
|
|
160
|
+
def post(id, qid, selected, options)
|
|
161
|
+
client = BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
162
|
+
return failure(worker_gone(id)) unless client
|
|
163
|
+
|
|
164
|
+
reply = nil
|
|
165
|
+
2.times do
|
|
166
|
+
reply = if options[:dismiss]
|
|
167
|
+
client.dismiss_question(id: qid)
|
|
168
|
+
else
|
|
169
|
+
client.answer(id: qid, selected: selected, freeform: options[:text],
|
|
170
|
+
client_id: Guardrails::ParentApprovals::CLIENT_ID)
|
|
171
|
+
end
|
|
172
|
+
break unless reply.status == 408
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
case reply.status
|
|
176
|
+
when 200 then nil
|
|
177
|
+
when 409 then not_open(qid)
|
|
178
|
+
when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
|
|
179
|
+
# The worker's own guardrails.parent_approvals refused the allow.
|
|
180
|
+
when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
|
|
181
|
+
when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
|
|
182
|
+
when 404
|
|
183
|
+
return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
|
|
184
|
+
|
|
185
|
+
cant = options[:dismiss] ? "dismiss a question" : "take an answer"
|
|
186
|
+
failure(BridgeClient.stale_worker_message(id, cant: cant))
|
|
187
|
+
else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
|
|
188
|
+
end
|
|
189
|
+
rescue Errno::ETIMEDOUT
|
|
190
|
+
failure("the worker did not take the answer in time; the question is still open: try again")
|
|
191
|
+
rescue SystemCallError, IOError, SocketError
|
|
192
|
+
failure(worker_gone(id))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The question was answered elsewhere (the web first), or another one
|
|
196
|
+
# waits now: said, then what comes next is waited for.
|
|
197
|
+
# @return [nil]
|
|
198
|
+
def not_open(qid)
|
|
199
|
+
error_line("#{command_name}: question #{qid} is no longer open; not answered here, waiting for what comes next")
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def worker_gone(id)
|
|
204
|
+
"the worker is gone and the question with it; send the task again: chi send --wait -m \"…\" #{id}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [Integer] 1, after the line
|
|
208
|
+
def failure(line)
|
|
209
|
+
error_line("#{command_name}: #{line}")
|
|
210
|
+
1
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The Bridge refused the answer itself (an option not offered, two on a
|
|
214
|
+
# single-select question): a usage error, the question still open.
|
|
215
|
+
def usage_failure(line)
|
|
216
|
+
error_line("#{command_name}: #{line}")
|
|
217
|
+
CLI::Exit::USAGE
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "bridge/sse_writer"
|
|
|
18
18
|
require_relative "bridge/turn_accumulator"
|
|
19
19
|
require_relative "session"
|
|
20
20
|
require_relative "engine"
|
|
21
|
+
require_relative "guardrails/parent_approvals"
|
|
21
22
|
require_relative "session_commands"
|
|
22
23
|
require_relative "image_store"
|
|
23
24
|
require_relative "log"
|
|
@@ -153,7 +154,7 @@ module Samagotchi
|
|
|
153
154
|
@capture_handle = @engine.subscribe(observer: capture_observer)
|
|
154
155
|
@accumulator_handle = @engine.subscribe(observer: @accumulator)
|
|
155
156
|
# A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
|
|
156
|
-
@engine.running_turn_messages = -> { @accumulator.current_messages }
|
|
157
|
+
@engine.running_turn_messages = -> { @accumulator.current_messages }
|
|
157
158
|
@cards_handle = @engine.subscribe(observer: @cards)
|
|
158
159
|
# One a worker that died left is not open.
|
|
159
160
|
@pending_card.clear
|
|
@@ -180,7 +181,7 @@ module Samagotchi
|
|
|
180
181
|
end
|
|
181
182
|
@capture_handle&.unsubscribe
|
|
182
183
|
@accumulator_handle&.unsubscribe
|
|
183
|
-
@engine.running_turn_messages = nil
|
|
184
|
+
@engine.running_turn_messages = nil
|
|
184
185
|
@cards_handle&.unsubscribe
|
|
185
186
|
@pending_card_handle&.unsubscribe
|
|
186
187
|
@pending_card&.clear
|
|
@@ -217,7 +218,7 @@ module Samagotchi
|
|
|
217
218
|
continue_offer: @accumulator.continue_offer,
|
|
218
219
|
guardrail_warning: @engine.guardrail_warning,
|
|
219
220
|
plugin_warning: @engine.plugin_warning,
|
|
220
|
-
init_tasks: @engine.
|
|
221
|
+
init_tasks: @engine.init_tasks,
|
|
221
222
|
cards: @cards.list,
|
|
222
223
|
commands: command_registry.listing,
|
|
223
224
|
event_seq: seq,
|
|
@@ -242,7 +243,7 @@ module Samagotchi
|
|
|
242
243
|
|
|
243
244
|
# The Engine's commands: the built-ins and its plugins'.
|
|
244
245
|
def command_registry
|
|
245
|
-
@engine.
|
|
246
|
+
@engine.command_registry
|
|
246
247
|
end
|
|
247
248
|
|
|
248
249
|
# The single shared capture observer: appends every event to the ring.
|
|
@@ -496,6 +497,10 @@ module Samagotchi
|
|
|
496
497
|
qid = fetched(parsed, "id")
|
|
497
498
|
selected = fetched(parsed, "selected")
|
|
498
499
|
freeform = fetched(parsed, "freeform")
|
|
500
|
+
# chi answer's marker: a parent agent, held to this worker's
|
|
501
|
+
# guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
|
|
502
|
+
# any local process may post here, with or without it.
|
|
503
|
+
client_id = fetched(parsed, "client_id")
|
|
499
504
|
if qid.to_s.strip.empty?
|
|
500
505
|
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
|
|
501
506
|
end
|
|
@@ -504,11 +509,15 @@ module Samagotchi
|
|
|
504
509
|
return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
|
|
505
510
|
|
|
506
511
|
begin
|
|
507
|
-
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
|
|
512
|
+
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
|
|
508
513
|
[{}, 200, { status: "answered", session_id: session_id, answer: result }]
|
|
509
514
|
rescue Engine::QuestionNotPending => e
|
|
510
515
|
# Another client answered first, or the question was cancelled.
|
|
511
516
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
517
|
+
rescue QuestionDesk::Refused => e
|
|
518
|
+
Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
|
|
519
|
+
[{}, 403, { error: "parent_approval_refused",
|
|
520
|
+
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
512
521
|
rescue ArgumentError => e
|
|
513
522
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
514
523
|
end
|
|
@@ -888,6 +897,7 @@ module Samagotchi
|
|
|
888
897
|
202 => "Accepted",
|
|
889
898
|
204 => "No Content",
|
|
890
899
|
400 => "Bad Request",
|
|
900
|
+
403 => "Forbidden",
|
|
891
901
|
404 => "Not Found",
|
|
892
902
|
409 => "Conflict",
|
|
893
903
|
500 => "Internal Server Error",
|
|
@@ -108,10 +108,15 @@ module Samagotchi
|
|
|
108
108
|
|
|
109
109
|
# POST /session/:id/answer. 200 = recorded, 409 = another client answered
|
|
110
110
|
# first (or the question is gone), 400 = invalid selection, 408
|
|
111
|
-
# deadline_passed = read too late and dropped (see DEADLINE_SHARE)
|
|
111
|
+
# deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
|
|
112
|
+
# parent_approval_refused = a parent's allow (client_id cli:answer)
|
|
113
|
+
# beyond the worker's guardrails.parent_approvals.
|
|
114
|
+
# @param client_id [String, nil] who answers (chi answer marks its own)
|
|
112
115
|
# @return [Response]
|
|
113
|
-
def answer(id:, selected:, freeform: nil)
|
|
114
|
-
|
|
116
|
+
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
117
|
+
body = { id: id, selected: selected, freeform: freeform, deadline: deadline }
|
|
118
|
+
body[:client_id] = client_id if client_id
|
|
119
|
+
post("answer", body, read_body: true)
|
|
115
120
|
end
|
|
116
121
|
|
|
117
122
|
# POST /session/:id/question/dismiss: leave the question unanswered.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "cli/flags"
|
|
4
|
+
require_relative "cli/exit"
|
|
4
5
|
require_relative "memory_bundle"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
@@ -109,7 +110,7 @@ module Samagotchi
|
|
|
109
110
|
when "build" then build(@argv[1..])
|
|
110
111
|
else
|
|
111
112
|
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build"
|
|
112
|
-
return
|
|
113
|
+
return CLI::Exit::USAGE
|
|
113
114
|
end
|
|
114
115
|
end
|
|
115
116
|
|
|
@@ -124,7 +125,7 @@ module Samagotchi
|
|
|
124
125
|
force = opts.fetch(:force, false)
|
|
125
126
|
if source.nil? || source.empty?
|
|
126
127
|
@stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
|
|
127
|
-
return
|
|
128
|
+
return CLI::Exit::USAGE
|
|
128
129
|
end
|
|
129
130
|
expanded_source = expand_source(source)
|
|
130
131
|
if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
|
|
@@ -148,7 +149,7 @@ module Samagotchi
|
|
|
148
149
|
agent = opts[:agent]
|
|
149
150
|
if source.nil? || source.empty?
|
|
150
151
|
@stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
|
|
151
|
-
return
|
|
152
|
+
return CLI::Exit::USAGE
|
|
152
153
|
end
|
|
153
154
|
expanded_source = expand_source(source)
|
|
154
155
|
if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
|
|
@@ -217,7 +218,11 @@ module Samagotchi
|
|
|
217
218
|
|
|
218
219
|
# An upgrade that kept edited files: launch an agent to merge them
|
|
219
220
|
# (--agent, or yes on a terminal), or say how to take the bundle's.
|
|
220
|
-
#
|
|
221
|
+
# An upgrade that kept edited files exits 2, as a usage error does
|
|
222
|
+
# (scripts already read it so): not CLI::Exit::USAGE by meaning.
|
|
223
|
+
CONFLICTS_KEPT = 2
|
|
224
|
+
|
|
225
|
+
# @return [Integer] 0 resolved, CONFLICTS_KEPT kept the edits
|
|
221
226
|
def upgrade_conflicts(installer, bundle_name, expanded_source, agent)
|
|
222
227
|
@stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
|
|
223
228
|
installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
|
|
@@ -227,13 +232,13 @@ module Samagotchi
|
|
|
227
232
|
ans && ans.strip.downcase.start_with?("y")
|
|
228
233
|
elsif agent.nil?
|
|
229
234
|
@stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
|
|
230
|
-
return
|
|
235
|
+
return CONFLICTS_KEPT
|
|
231
236
|
else
|
|
232
237
|
agent
|
|
233
238
|
end
|
|
234
239
|
unless launch
|
|
235
240
|
@stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
|
|
236
|
-
return
|
|
241
|
+
return CONFLICTS_KEPT
|
|
237
242
|
end
|
|
238
243
|
|
|
239
244
|
prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
|
|
@@ -258,7 +263,7 @@ module Samagotchi
|
|
|
258
263
|
force = opts.fetch(:force, false)
|
|
259
264
|
if bundle_name.nil? || bundle_name.empty?
|
|
260
265
|
@stderr.puts "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
|
|
261
|
-
return
|
|
266
|
+
return CLI::Exit::USAGE
|
|
262
267
|
end
|
|
263
268
|
data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
|
|
264
269
|
return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
|
|
@@ -355,6 +360,8 @@ module Samagotchi
|
|
|
355
360
|
exists = File.exist?(hook_path) ? "ok" : "missing"
|
|
356
361
|
@stdout.puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
|
|
357
362
|
end
|
|
363
|
+
@stdout.puts " requires_chi: #{st[:provenance][:requires_chi]}" if st[:hooks_requires_failure]
|
|
364
|
+
@stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
|
|
358
365
|
end
|
|
359
366
|
if (plugin = st[:plugin])
|
|
360
367
|
note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
|
|
@@ -379,6 +386,7 @@ module Samagotchi
|
|
|
379
386
|
hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
|
|
380
387
|
plugin = st[:plugin]
|
|
381
388
|
mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
|
|
389
|
+
mods += 1 if st[:hooks_requires_failure]
|
|
382
390
|
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
383
391
|
includes = st[:provenance][:includes]
|
|
384
392
|
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
@@ -393,7 +401,7 @@ module Samagotchi
|
|
|
393
401
|
bname = args[0]
|
|
394
402
|
file_arg = args[1]
|
|
395
403
|
if bname.nil? || bname.empty?
|
|
396
|
-
@stderr.puts "Usage: chi bundle diff <bundle> [file]"; return
|
|
404
|
+
@stderr.puts "Usage: chi bundle diff <bundle> [file]"; return CLI::Exit::USAGE
|
|
397
405
|
end
|
|
398
406
|
prov = Samagotchi::MemoryBundle::Provenance.new(name: bname)
|
|
399
407
|
data = prov.read
|
|
@@ -534,7 +542,7 @@ module Samagotchi
|
|
|
534
542
|
# Validate scope if given
|
|
535
543
|
if scope && !%w[system project].include?(scope.to_s.strip.downcase)
|
|
536
544
|
@stderr.puts "Invalid scope '#{scope}', expected system or project"
|
|
537
|
-
return
|
|
545
|
+
return CLI::Exit::USAGE
|
|
538
546
|
end
|
|
539
547
|
begin
|
|
540
548
|
builder = Samagotchi::MemoryBundle::Builder.new(
|
|
@@ -573,7 +581,7 @@ module Samagotchi
|
|
|
573
581
|
end
|
|
574
582
|
if parsed.error
|
|
575
583
|
@stderr.puts "Unknown bundle #{sub} flag: #{parsed.error.arg}"
|
|
576
|
-
return
|
|
584
|
+
return CLI::Exit::USAGE
|
|
577
585
|
end
|
|
578
586
|
parsed
|
|
579
587
|
end
|
|
@@ -80,3 +80,22 @@ rules:
|
|
|
80
80
|
command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
|
|
81
81
|
verdict: ask
|
|
82
82
|
reason: the command names chi's config, hooks, guardrails or git hooks
|
|
83
|
+
|
|
84
|
+
# ── answering chi's own questions around chi answer ───────────────────
|
|
85
|
+
# A parent agent answers a child's approval with chi answer, which holds it
|
|
86
|
+
# to guardrails.parent_approvals. These shapes go around that: chi --attach
|
|
87
|
+
# or chi -p with stdin from a pipe, a here-string or a file (a chi after 0.12 treats
|
|
88
|
+
# those as a parent too, but an older child doesn't), and a POST to the
|
|
89
|
+
# Bridge's or the web's answer route.
|
|
90
|
+
- id: chi-answer-piped
|
|
91
|
+
tool: shell
|
|
92
|
+
command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
|
|
93
|
+
verdict: ask
|
|
94
|
+
scopes: [once, session]
|
|
95
|
+
reason: answers chi's questions from a pipe or a file, around chi answer
|
|
96
|
+
- id: chi-answer-http
|
|
97
|
+
tool: shell
|
|
98
|
+
command: '\b(curl|wget|http|xh)\b[^;&|\n]*/answer\b'
|
|
99
|
+
verdict: ask
|
|
100
|
+
scopes: [once, session]
|
|
101
|
+
reason: posts an answer to a chi session's question directly, around chi answer
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.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), 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 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
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
# models: in small-models.yml (guardrails.small_models)
|
|
8
8
|
requires_chi: ">= 0.8.1"
|
|
@@ -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; env `
|
|
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.
|
|
64
64
|
|
|
65
65
|
```yaml
|
|
66
66
|
guardrails:
|
|
@@ -122,7 +122,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
122
122
|
3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
|
|
123
123
|
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
|
|
124
124
|
5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
|
|
125
|
-
6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`,
|
|
125
|
+
6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
|
|
126
126
|
|
|
127
127
|
## Validations
|
|
128
128
|
|
|
@@ -136,8 +136,8 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
136
136
|
- must not point to itself (case-insensitive)
|
|
137
137
|
- keys are normalized to downcase on write — `Qwen` and `qwen` collide
|
|
138
138
|
- **Alias target**: non-empty string (model id).
|
|
139
|
-
- **Hosts**: each entry needs `host
|
|
140
|
-
- **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log
|
|
139
|
+
- **Hosts**: each entry needs `host` (or `url:`, an http(s) URL with an optional path, replacing `host`/`port`; never both), `port` 1-65535 (default 8080), `enabled: false` skips the entry, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
|
|
140
|
+
- **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`; `required: true` on a `before_tool_call` hook denies the call when the hook raises. Class name must match file basename snake→Pascal.
|
|
141
141
|
- **Scalars via registry**: `Config.coerce` validates `String/Numeric/true/false` per `type: :string/:integer/:float/:bool/:enum`; invalid values warn and fall back to entry `default`.
|
|
142
142
|
- **Keys**: `validate_yaml_sections` flags unknown keys (with a suggestion), a registry section that isn't a mapping, and env/CLI-only keys (`model.profile`).
|
|
143
143
|
|
|
@@ -151,5 +151,5 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
151
151
|
|
|
152
152
|
- Precedence is `CLI > ENV > file > default` (`Config.resolve`; `Config.get_with_origin` names the layer). `ENV` holds only what the user (or a spawning chi's CLI flags) set, and CLI (`--recap-base-url`) wins over both via `Config.reload!(cli_overrides:)`.
|
|
153
153
|
- `--recap_base_url` (underscore) is rejected as unknown — use `--recap-base-url` (kebab). Same for all registry flags.
|
|
154
|
-
- `model_aliases`
|
|
154
|
+
- `model_aliases` are read again at each model resolution: an edit applies to the next `--model`, `/model`, new session or delegate, no restart. A running session keeps the model it already resolved.
|
|
155
155
|
- Keep edits minimal: touch only the key you intend to change; preserve `hosts:`/`hooks:`/`guardrails:`/`bundles:` maps. Adding a `bundles: <name>:` entry does not install the bundle (`chi bundle install <name>`).
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.13.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:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
|
|
10
10
|
memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "exit"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module CLI
|
|
5
7
|
# The usage side of a subcommand: its error lines, usage errors (exit 2)
|
|
@@ -7,7 +9,6 @@ module Samagotchi
|
|
|
7
9
|
# #command_name ("chi send"); its usage text is USAGE unless it
|
|
8
10
|
# overrides #usage_text.
|
|
9
11
|
module Command
|
|
10
|
-
USAGE_EXIT = 2
|
|
11
12
|
HELP_WORDS = %w[-h --help help].freeze
|
|
12
13
|
|
|
13
14
|
private
|
|
@@ -30,7 +31,7 @@ module Samagotchi
|
|
|
30
31
|
usage = usage_on_error
|
|
31
32
|
error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
|
|
32
33
|
@stderr.puts(usage) if usage
|
|
33
|
-
|
|
34
|
+
Exit::USAGE
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# +argv+ through +flags+ (a Flags), help and usage errors done.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module CLI
|
|
5
|
+
# chi's exit statuses, one table for every subcommand (docs/cli.md,
|
|
6
|
+
# docs/sub-agent.md): a script or a parent agent branches on them.
|
|
7
|
+
module Exit
|
|
8
|
+
OK = 0
|
|
9
|
+
# Anything that went wrong at run time: a refusal, a failed turn, a
|
|
10
|
+
# worker gone, a file that isn't there.
|
|
11
|
+
FAILED = 1
|
|
12
|
+
# The command line itself: an unknown flag, a missing argument, an
|
|
13
|
+
# option the question doesn't offer.
|
|
14
|
+
USAGE = 2
|
|
15
|
+
# A question waits for an answer (chi send --wait, chi answer, chi -p).
|
|
16
|
+
QUESTION = 3
|
|
17
|
+
# Still running after --timeout.
|
|
18
|
+
RUNNING = 4
|
|
19
|
+
# Ctrl-C: what a shell gives a command it interrupted.
|
|
20
|
+
INTERRUPTED = 130
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|