samagotchi 0.12.0 → 0.14.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 +116 -1
- data/README.md +20 -7
- data/bin/chi +9 -9
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +79 -15
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +74 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +31 -16
- 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/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +14 -2
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- 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 +81 -24
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +54 -17
- data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +31 -7
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- 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/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +40 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +11 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +32 -13
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -10
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- metadata +8 -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
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../atomic_file"
|
|
3
6
|
require_relative "../events"
|
|
4
7
|
|
|
5
8
|
module Samagotchi
|
|
@@ -20,32 +23,70 @@ module Samagotchi
|
|
|
20
23
|
# A card with an earlier card's id replaces it where it was. Each entry
|
|
21
24
|
# says where it belongs as the recap does: +turns_since+, the turns
|
|
22
25
|
# completed after it (after its own turn for a card shown during one),
|
|
23
|
-
# and +current+ for a card of the turn running now.
|
|
24
|
-
# this worker's start; the ones before it are in no entry. A card that
|
|
26
|
+
# and +current+ for a card of the turn running now. A card that
|
|
25
27
|
# isn't the turn's but comes while one runs (an anytime command's, such
|
|
26
28
|
# as /btw) goes after that turn: its prompt is already in the history.
|
|
27
29
|
# A failed turn leaves no prompt, so its cards stay before the next one.
|
|
30
|
+
#
|
|
31
|
+
# Notices (hook notices, load warnings, "asking again" rows) and cards
|
|
32
|
+
# (with questions) are capped apart, +capacity+ each, so a burst of
|
|
33
|
+
# notices can't push out a card. An open question (an approval too) is
|
|
34
|
+
# never pushed out: the oldest other card or answered question goes.
|
|
35
|
+
#
|
|
36
|
+
# With a +path+ (the Bridge's: FILE in the session's folder) the store
|
|
37
|
+
# saves its entries and turn count there on each change, and a later
|
|
38
|
+
# worker's store starts from them, so a turn's cards and notices outlive
|
|
39
|
+
# the worker that showed them; the web reads the file (.saved) for a
|
|
40
|
+
# session no worker runs. Turns count on from the saved count: the
|
|
41
|
+
# placement is relative (turns since), so only the turns after an entry
|
|
42
|
+
# matter. The load warnings stay the worker's own (each start announces
|
|
43
|
+
# its own), and a question still open when the worker went waits for no
|
|
44
|
+
# one: neither is saved or seeded. Same caps as in memory. An entry from
|
|
45
|
+
# an earlier worker lists with +earlier+: true (an attached TUI's resync
|
|
46
|
+
# onto a new worker doesn't print those again).
|
|
28
47
|
class CardStore
|
|
29
48
|
CAPACITY = 20
|
|
49
|
+
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry].freeze
|
|
50
|
+
FILE = "cards.json"
|
|
51
|
+
# The events after which the file is written again: the ones that
|
|
52
|
+
# change an entry or the turn count.
|
|
53
|
+
SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
|
|
54
|
+
turn_failed] + Events::TURN_KEPT).freeze
|
|
55
|
+
|
|
56
|
+
# What a session's saved file lists, as a store started from it lists
|
|
57
|
+
# (no turn running): the web's cards for a session no worker runs.
|
|
58
|
+
# @param session_dir [String]
|
|
59
|
+
# @return [Array<Hash>] [] without a readable file
|
|
60
|
+
def self.saved(session_dir)
|
|
61
|
+
new(path: File.join(session_dir, FILE)).list
|
|
62
|
+
end
|
|
30
63
|
|
|
31
|
-
|
|
64
|
+
# @param path [String, nil] the file the entries are saved in and
|
|
65
|
+
# started from; nil keeps them in memory only
|
|
66
|
+
def initialize(capacity: CAPACITY, path: nil)
|
|
32
67
|
@capacity = capacity
|
|
68
|
+
@path = path
|
|
33
69
|
@mutex = Mutex.new
|
|
34
70
|
@entries = []
|
|
35
71
|
@turns_done = 0
|
|
36
72
|
@running = false
|
|
37
73
|
@iteration = nil
|
|
38
74
|
@calls = 0
|
|
75
|
+
seed
|
|
39
76
|
end
|
|
40
77
|
|
|
41
78
|
def call(event)
|
|
42
|
-
@mutex.synchronize
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
fold(event)
|
|
81
|
+
save if SAVED_ON.include?(event[:type])
|
|
82
|
+
end
|
|
43
83
|
rescue StandardError
|
|
44
84
|
nil # never break the running turn
|
|
45
85
|
end
|
|
46
86
|
|
|
47
87
|
# @return [Array<Hash>] oldest first: the card's (or notice's) fields
|
|
48
|
-
# plus turns_since: and current:,
|
|
88
|
+
# plus turns_since: and current:, earlier: true for one an earlier
|
|
89
|
+
# worker saved, and during: true for a card that
|
|
49
90
|
# came while the running turn runs but isn't that turn's (it goes
|
|
50
91
|
# after the running turn's prompt)
|
|
51
92
|
def list
|
|
@@ -63,6 +104,42 @@ module Samagotchi
|
|
|
63
104
|
|
|
64
105
|
private
|
|
65
106
|
|
|
107
|
+
def save
|
|
108
|
+
return unless @path
|
|
109
|
+
|
|
110
|
+
entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
|
|
111
|
+
# A session that never showed one gets no file (the turn count
|
|
112
|
+
# only places entries relative to each other).
|
|
113
|
+
return if entries.empty? && !File.exist?(@path)
|
|
114
|
+
|
|
115
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
116
|
+
AtomicFile.write(@path, JSON.generate({ "version" => 1, "turns" => @turns_done, "entries" => entries }))
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil # a card that can't be saved still shows live
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The entries an earlier worker saved (a missing or broken file:
|
|
122
|
+
# none), their types as symbols again; a card that came during that
|
|
123
|
+
# worker's last turn stays where it was, as after a failed turn.
|
|
124
|
+
def seed
|
|
125
|
+
return unless @path && File.file?(@path)
|
|
126
|
+
|
|
127
|
+
data = JSON.parse(File.read(@path), symbolize_names: true)
|
|
128
|
+
return unless data.is_a?(Hash) && data[:turns].is_a?(Integer) && data[:entries].is_a?(Array)
|
|
129
|
+
|
|
130
|
+
@turns_done = data[:turns]
|
|
131
|
+
@entries = data[:entries].filter_map do |entry|
|
|
132
|
+
next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
|
|
133
|
+
|
|
134
|
+
entry = entry.merge(type: entry[:type].to_sym, earlier: true)
|
|
135
|
+
entry unless entry[:type] == :guardrail_warning || open_question?(entry)
|
|
136
|
+
end
|
|
137
|
+
settle_during(after_turn: false)
|
|
138
|
+
rescue StandardError
|
|
139
|
+
@turns_done = 0
|
|
140
|
+
@entries = []
|
|
141
|
+
end
|
|
142
|
+
|
|
66
143
|
def fold(event)
|
|
67
144
|
case event[:type]
|
|
68
145
|
when :turn_started
|
|
@@ -144,7 +221,18 @@ module Samagotchi
|
|
|
144
221
|
|
|
145
222
|
def push(entry)
|
|
146
223
|
@entries << entry
|
|
147
|
-
|
|
224
|
+
notice = notice?(entry)
|
|
225
|
+
kind = @entries.select { |e| notice?(e) == notice }
|
|
226
|
+
return if kind.size <= @capacity
|
|
227
|
+
|
|
228
|
+
evict = kind.find { |e| !open_question?(e) }
|
|
229
|
+
@entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def notice?(entry) = NOTICE_TYPES.include?(entry[:type])
|
|
233
|
+
|
|
234
|
+
def open_question?(entry)
|
|
235
|
+
entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
|
|
148
236
|
end
|
|
149
237
|
end
|
|
150
238
|
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"
|
|
@@ -94,7 +95,9 @@ module Samagotchi
|
|
|
94
95
|
@port = port
|
|
95
96
|
@ring = RingBuffer.new(capacity: ring_capacity)
|
|
96
97
|
@accumulator = TurnAccumulator.new
|
|
97
|
-
|
|
98
|
+
# Saved in the session's folder: a later worker, and the web without
|
|
99
|
+
# one, show the cards where they were.
|
|
100
|
+
@cards = CardStore.new(path: File.join(session_dir, CardStore::FILE))
|
|
98
101
|
@pending_card = PendingCard.new(session_dir)
|
|
99
102
|
@heartbeat_interval = heartbeat_interval
|
|
100
103
|
@epoch = SecureRandom.hex(4)
|
|
@@ -496,6 +499,10 @@ module Samagotchi
|
|
|
496
499
|
qid = fetched(parsed, "id")
|
|
497
500
|
selected = fetched(parsed, "selected")
|
|
498
501
|
freeform = fetched(parsed, "freeform")
|
|
502
|
+
# chi answer's marker: a parent agent, held to this worker's
|
|
503
|
+
# guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
|
|
504
|
+
# any local process may post here, with or without it.
|
|
505
|
+
client_id = fetched(parsed, "client_id")
|
|
499
506
|
if qid.to_s.strip.empty?
|
|
500
507
|
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
|
|
501
508
|
end
|
|
@@ -504,11 +511,15 @@ module Samagotchi
|
|
|
504
511
|
return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
|
|
505
512
|
|
|
506
513
|
begin
|
|
507
|
-
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform)
|
|
514
|
+
result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
|
|
508
515
|
[{}, 200, { status: "answered", session_id: session_id, answer: result }]
|
|
509
516
|
rescue Engine::QuestionNotPending => e
|
|
510
517
|
# Another client answered first, or the question was cancelled.
|
|
511
518
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
519
|
+
rescue QuestionDesk::Refused => e
|
|
520
|
+
Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
|
|
521
|
+
[{}, 403, { error: "parent_approval_refused",
|
|
522
|
+
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
512
523
|
rescue ArgumentError => e
|
|
513
524
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
514
525
|
end
|
|
@@ -888,6 +899,7 @@ module Samagotchi
|
|
|
888
899
|
202 => "Accepted",
|
|
889
900
|
204 => "No Content",
|
|
890
901
|
400 => "Bad Request",
|
|
902
|
+
403 => "Forbidden",
|
|
891
903
|
404 => "Not Found",
|
|
892
904
|
409 => "Conflict",
|
|
893
905
|
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
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-in
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:0a5ba60962206b3ec90bbaa0934a7284416aef495df8a70391d9ea47f7eb9ec1
|
|
10
10
|
requires_chi: ">= 0.3.0"
|