samagotchi 0.15.0 → 0.17.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
|
@@ -51,7 +51,7 @@ module Samagotchi
|
|
|
51
51
|
# The events after which the file is written again: the ones that
|
|
52
52
|
# change an entry or the turn count.
|
|
53
53
|
SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
|
|
54
|
-
turn_failed] + Events::TURN_KEPT).freeze
|
|
54
|
+
question_relay turn_failed] + Events::TURN_KEPT).freeze
|
|
55
55
|
|
|
56
56
|
# What a session's saved file lists, as a store started from it lists
|
|
57
57
|
# (no turn running): the web's cards for a session no worker runs.
|
|
@@ -171,6 +171,7 @@ module Samagotchi
|
|
|
171
171
|
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
|
|
172
172
|
when :question_answered then resolve_question(event[:id], answer: event[:answer])
|
|
173
173
|
when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
|
|
174
|
+
when :question_relay then mark_relay(event[:id], event[:relayed_to])
|
|
174
175
|
end
|
|
175
176
|
end
|
|
176
177
|
|
|
@@ -219,6 +220,20 @@ module Samagotchi
|
|
|
219
220
|
entry&.merge!(Marshal.load(Marshal.dump(outcome.compact)))
|
|
220
221
|
end
|
|
221
222
|
|
|
223
|
+
# The open question was relayed to a parent's user, or no longer is.
|
|
224
|
+
def mark_relay(id, relayed_to)
|
|
225
|
+
entry = @entries.reverse_each.find do |e|
|
|
226
|
+
e[:type] == :question && e.dig(:pending_question, :id).to_s == id.to_s
|
|
227
|
+
end
|
|
228
|
+
return unless entry
|
|
229
|
+
|
|
230
|
+
if relayed_to
|
|
231
|
+
entry[:pending_question][:relayed_to] = Marshal.load(Marshal.dump(relayed_to))
|
|
232
|
+
else
|
|
233
|
+
entry[:pending_question].delete(:relayed_to)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
222
237
|
def push(entry)
|
|
223
238
|
@entries << entry
|
|
224
239
|
notice = notice?(entry)
|
|
@@ -41,11 +41,23 @@ module Samagotchi
|
|
|
41
41
|
# continue, ordered parts (thinking / text / tool / input / steer /
|
|
42
42
|
# reminder / notice), pending_question and the last event_seq folded
|
|
43
43
|
# in. A finished tool part keeps the live row's action and duration_ms
|
|
44
|
-
# too
|
|
45
|
-
#
|
|
46
|
-
#
|
|
44
|
+
# too, a running one the time it has run so far (elapsed_ms), so a
|
|
45
|
+
# join times its row from its start. A notice part holds the event
|
|
46
|
+
# of one of the turn's rows (TurnNotice: a hook's notice, an
|
|
47
|
+
# empty-answer retry, a question and its answer), which a UI replays
|
|
48
|
+
# through its live handler.
|
|
47
49
|
def current_turn
|
|
48
|
-
@mutex.synchronize
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
next nil unless @turn
|
|
52
|
+
|
|
53
|
+
turn = Marshal.load(Marshal.dump(@turn))
|
|
54
|
+
now = @clock.call
|
|
55
|
+
Array(turn[:parts]).each do |part|
|
|
56
|
+
started_at = part[:kind] == "tool" && part[:status] == "running" && @tool_started_at[[part[:iteration], part[:call_index]]]
|
|
57
|
+
part[:elapsed_ms] = ((now - started_at) * 1000).round if started_at
|
|
58
|
+
end
|
|
59
|
+
turn
|
|
60
|
+
end
|
|
49
61
|
end
|
|
50
62
|
|
|
51
63
|
# The turn in progress as conversation messages, for a plugin's
|
|
@@ -142,7 +154,10 @@ module Samagotchi
|
|
|
142
154
|
call[:label] = part[:label] if part[:label]
|
|
143
155
|
title = part[:title] ? { title: part[:title] } : {}
|
|
144
156
|
events = [{ type: :tool_call_started, **call, params: part[:params], **title }]
|
|
145
|
-
|
|
157
|
+
if part[:status] == "running"
|
|
158
|
+
events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
|
|
159
|
+
return events
|
|
160
|
+
end
|
|
146
161
|
|
|
147
162
|
activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
|
|
148
163
|
activity[:action] = part[:action] if part[:action]
|
|
@@ -268,6 +283,12 @@ module Samagotchi
|
|
|
268
283
|
@turn[:pending_question] = event[:pending_question]&.dup
|
|
269
284
|
when :question_answered, :question_cancelled
|
|
270
285
|
@turn[:pending_question] = nil
|
|
286
|
+
when :question_relay
|
|
287
|
+
pending = @turn[:pending_question]
|
|
288
|
+
if pending && pending[:id].to_s == event[:id].to_s
|
|
289
|
+
relayed_to = event[:relayed_to]
|
|
290
|
+
@turn[:pending_question] = relayed_to ? pending.merge(relayed_to: relayed_to) : pending.except(:relayed_to)
|
|
291
|
+
end
|
|
271
292
|
end
|
|
272
293
|
parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
|
|
273
294
|
end
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -24,6 +24,8 @@ require_relative "image_store"
|
|
|
24
24
|
require_relative "log"
|
|
25
25
|
require_relative "version"
|
|
26
26
|
require_relative "worker_sidecar"
|
|
27
|
+
require_relative "relay_verifier"
|
|
28
|
+
require_relative "relay_watcher"
|
|
27
29
|
|
|
28
30
|
module Samagotchi
|
|
29
31
|
# Bridge is an optional HTTP transport that lets an external web / desktop
|
|
@@ -192,6 +194,7 @@ module Samagotchi
|
|
|
192
194
|
await_answers(REQUEST_GRACE_SECONDS)
|
|
193
195
|
@connection_threads.each { |t| t.kill rescue nil }
|
|
194
196
|
@connection_threads.clear
|
|
197
|
+
@mutex.synchronize { @relay_watchers&.each { |t| t.kill rescue nil } }
|
|
195
198
|
@accept_thread&.join(2)
|
|
196
199
|
remove_sidecar
|
|
197
200
|
nil
|
|
@@ -241,6 +244,16 @@ module Samagotchi
|
|
|
241
244
|
end
|
|
242
245
|
end
|
|
243
246
|
|
|
247
|
+
# The worker's own way in for a session command that came as a message
|
|
248
|
+
# (its first prompt, an input file): queued and announced as a POST
|
|
249
|
+
# /command's is (#handle_command).
|
|
250
|
+
# @return [String] its command_id
|
|
251
|
+
def queue_command(line, client_id: nil)
|
|
252
|
+
command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
|
|
253
|
+
@engine.synchronize_events { queue_command_locked(command) }
|
|
254
|
+
command[:command_id]
|
|
255
|
+
end
|
|
256
|
+
|
|
244
257
|
private
|
|
245
258
|
|
|
246
259
|
# The Engine's commands: the built-ins and its plugins'.
|
|
@@ -429,6 +442,8 @@ module Samagotchi
|
|
|
429
442
|
%w[POST command] => :handle_command,
|
|
430
443
|
%w[POST exit] => :handle_exit_request,
|
|
431
444
|
%w[POST recap] => :handle_recap,
|
|
445
|
+
%w[POST relay] => :handle_relay,
|
|
446
|
+
%w[POST relay/status] => :handle_relay_status,
|
|
432
447
|
%w[GET state] => :handle_state,
|
|
433
448
|
%w[GET stats] => :handle_stats,
|
|
434
449
|
%w[GET snapshot] => :handle_snapshot
|
|
@@ -525,6 +540,38 @@ module Samagotchi
|
|
|
525
540
|
end
|
|
526
541
|
end
|
|
527
542
|
|
|
543
|
+
# What a parent's relay may say about this session's approval
|
|
544
|
+
# (BridgeClient#relay): opened and closed only change how the question
|
|
545
|
+
# shows (QuestionDesk#annotate); answered makes this worker ask the
|
|
546
|
+
# parent for the answer (RelayVerifier). No auth: a poke at opened or
|
|
547
|
+
# closed can at most show a wrong banner.
|
|
548
|
+
RELAY_ACTIONS = %w[opened closed answered].freeze
|
|
549
|
+
# Why a relay closed, as a parent says it; anything else is "closed".
|
|
550
|
+
RELAY_CLOSE_REASONS = %w[stopped closed child_gone answered_on_child].freeze
|
|
551
|
+
|
|
552
|
+
# POST /session/:id/relay {action, relay_id, question_id}. 200, 409 (no
|
|
553
|
+
# longer pending, or another relay's), 403 refused (a parent agent's
|
|
554
|
+
# allow), 422 relay_unverified, 400 a bad request.
|
|
555
|
+
def handle_relay(session_id, body)
|
|
556
|
+
parsed = parse_json(body)
|
|
557
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
558
|
+
|
|
559
|
+
action, relay_id, qid = %w[action relay_id question_id].map { |key| fetched(parsed, key).to_s }
|
|
560
|
+
unless RELAY_ACTIONS.include?(action) && !relay_id.empty? && !qid.empty?
|
|
561
|
+
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "action (opened, closed, answered), relay_id and question_id required" }]
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
case action
|
|
565
|
+
when "opened" then relay_opened(qid, relay_id)
|
|
566
|
+
when "closed"
|
|
567
|
+
reason = fetched(parsed, "reason").to_s
|
|
568
|
+
relay_closed(qid, relay_id, RELAY_CLOSE_REASONS.include?(reason) ? reason : "closed")
|
|
569
|
+
else
|
|
570
|
+
RelayVerifier.new(engine: @engine, state_dir: @state_dir, session_id: session_id)
|
|
571
|
+
.answer(relay_id: relay_id, question_id: qid)
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
528
575
|
# Dismiss the pending question (an empty answer, as in the REPL): the
|
|
529
576
|
# tool returns without an answer and every UI gets :question_cancelled.
|
|
530
577
|
# Only the question the client saw, and only while nobody has answered
|
|
@@ -575,17 +622,21 @@ module Samagotchi
|
|
|
575
622
|
deadline = fetched(parsed, "deadline")
|
|
576
623
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
577
624
|
|
|
578
|
-
|
|
625
|
+
queue_command_request(session_id, line, client_id: fetched(parsed, "client_id"), deadline: deadline,
|
|
626
|
+
card: fetched(parsed, "card") == true)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# Queues +line+ (a session command) for the worker and announces its
|
|
630
|
+
# :command_queued, unless +deadline+ has passed. Returns [headers,
|
|
631
|
+
# status, body]: 202 with its command_id, or 408.
|
|
632
|
+
def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
|
|
633
|
+
command = { command_id: SecureRandom.uuid, client_id: client_id, line: line }
|
|
579
634
|
# A card's action: the UIs leave its line out (the card is the echo).
|
|
580
|
-
command[:card] = true if
|
|
635
|
+
command[:card] = true if card
|
|
581
636
|
queued = @engine.synchronize_events do
|
|
582
637
|
next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
|
|
583
638
|
|
|
584
|
-
|
|
585
|
-
# An anytime command runs now, beside a turn (D8): the UIs show its
|
|
586
|
-
# line here, so the cards it shows come after it.
|
|
587
|
-
anytime = command_registry.lookup(line)&.anytime ? { anytime: true } : {}
|
|
588
|
-
@engine.announce(type: :command_queued, **command, **anytime)
|
|
639
|
+
queue_command_locked(command)
|
|
589
640
|
true
|
|
590
641
|
end
|
|
591
642
|
return deadline_passed("command") unless queued
|
|
@@ -593,6 +644,15 @@ module Samagotchi
|
|
|
593
644
|
[{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
|
|
594
645
|
end
|
|
595
646
|
|
|
647
|
+
# With the event log held.
|
|
648
|
+
def queue_command_locked(command)
|
|
649
|
+
@on_command.call(command)
|
|
650
|
+
# An anytime command runs now, beside a turn (D8): the UIs show its
|
|
651
|
+
# line here, so the cards it shows come after it.
|
|
652
|
+
anytime = command_registry.lookup(command[:line])&.anytime ? { anytime: true } : {}
|
|
653
|
+
@engine.announce(type: :command_queued, **command, **anytime)
|
|
654
|
+
end
|
|
655
|
+
|
|
596
656
|
# A client asks the worker to exit now (`/exit` in the attached TUI). The
|
|
597
657
|
# worker decides with the event log held, so no POST /turn lands between
|
|
598
658
|
# its check and its answer; it leaves from its loop after this reply.
|
|
@@ -651,6 +711,14 @@ module Samagotchi
|
|
|
651
711
|
deadline = fetched(parsed, "deadline")
|
|
652
712
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
653
713
|
|
|
714
|
+
# A session command sent as a message (chi send -m "/model x", chi -p,
|
|
715
|
+
# a UI whose command list lags) runs as the command, as typed in a
|
|
716
|
+
# TUI; an unknown /word stays a prompt. Not with images: those are
|
|
717
|
+
# for the model.
|
|
718
|
+
if images.empty? && @on_command && command_registry.command?(prompt.to_s)
|
|
719
|
+
return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
|
|
720
|
+
end
|
|
721
|
+
|
|
654
722
|
enqueued_id = SecureRandom.uuid
|
|
655
723
|
# Write and announce with the event log held: the worker can't emit
|
|
656
724
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
@@ -872,6 +940,53 @@ module Samagotchi
|
|
|
872
940
|
headers["connection"].to_s.downcase == "close"
|
|
873
941
|
end
|
|
874
942
|
|
|
943
|
+
# POST /session/:id/relay/status {relay_id}: what this (the parent's)
|
|
944
|
+
# worker's relay holds, for the child that verifies an answer
|
|
945
|
+
# (RelayVerifier). Read-only and no secret; 404 for an unknown id.
|
|
946
|
+
def handle_relay_status(_session_id, body)
|
|
947
|
+
parsed = parse_json(body)
|
|
948
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
949
|
+
|
|
950
|
+
relay = @engine.relay_desk.status(fetched(parsed, "relay_id").to_s)
|
|
951
|
+
return [{}, 404, { error: "unknown_relay" }] unless relay
|
|
952
|
+
|
|
953
|
+
[{}, 200, relay]
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
# A parent's relay card for +qid+ opened: the question says so (the
|
|
957
|
+
# child's card and lists show where else it can be answered).
|
|
958
|
+
def relay_opened(qid, relay_id)
|
|
959
|
+
parent_id = begin
|
|
960
|
+
Session.load(@session_id, state_dir: @state_dir).parent_id
|
|
961
|
+
rescue ArgumentError
|
|
962
|
+
nil
|
|
963
|
+
end
|
|
964
|
+
return [{}, 422, { error: "relay_unverified", detail: "this session has no parent" }] unless parent_id
|
|
965
|
+
|
|
966
|
+
marker = { parent_id: parent_id, parent_short: parent_id[0, 8], relay_id: relay_id }
|
|
967
|
+
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: marker)
|
|
968
|
+
|
|
969
|
+
Log.info(:bridge, "relay_opened", sid: @session_id, id: qid, parent: parent_id[0, 8])
|
|
970
|
+
watch = RelayWatcher.start(engine: @engine, question_id: qid, relay_id: relay_id,
|
|
971
|
+
parent_dir: Session.session_dir(parent_id, state_dir: @state_dir))
|
|
972
|
+
@mutex.synchronize { @relay_watchers = (@relay_watchers || []).select(&:alive?) << watch }
|
|
973
|
+
[{}, 200, { status: "opened", question_id: qid }]
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# The parent's relay card closed unanswered: the mark goes, if it is
|
|
977
|
+
# still that relay's.
|
|
978
|
+
def relay_closed(qid, relay_id, reason)
|
|
979
|
+
pending = @engine.pending_question
|
|
980
|
+
relay = pending && pending[:relayed_to]
|
|
981
|
+
unless pending && pending[:id].to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
|
|
982
|
+
return [{}, 409, { error: "question_not_pending", detail: "no question #{qid} relayed by #{relay_id}" }]
|
|
983
|
+
end
|
|
984
|
+
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: nil, reason: reason)
|
|
985
|
+
|
|
986
|
+
Log.info(:bridge, "relay_closed", sid: @session_id, id: qid, reason: reason)
|
|
987
|
+
[{}, 200, { status: "closed", question_id: qid }]
|
|
988
|
+
end
|
|
989
|
+
|
|
875
990
|
def write_sidecar
|
|
876
991
|
# version: the chi this worker runs (chi update reports older ones).
|
|
877
992
|
WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
|
|
@@ -180,6 +180,33 @@ module Samagotchi
|
|
|
180
180
|
post("cancel", { reason: reason }, read_body: true)
|
|
181
181
|
end
|
|
182
182
|
|
|
183
|
+
# How long the approval relay's calls wait for a reply: a parent's Stop
|
|
184
|
+
# isn't held for READ_TIMEOUT by a child that doesn't answer.
|
|
185
|
+
RELAY_TIMEOUT = 5
|
|
186
|
+
# How long a child waits for its parent's word on a relay (#relay_status).
|
|
187
|
+
RELAY_STATUS_TIMEOUT = 2
|
|
188
|
+
|
|
189
|
+
# POST /session/:id/relay on a child's worker: a parent's relay of the
|
|
190
|
+
# child's approval opened, closed or was answered (the child asks the
|
|
191
|
+
# parent which answer, #relay_status). 200 answered, 409 no longer
|
|
192
|
+
# pending, 403 refused (a parent agent's allow), 422 relay_unverified.
|
|
193
|
+
# @param action [String] "opened", "closed" or "answered"
|
|
194
|
+
# @return [Response]
|
|
195
|
+
# @raise [SystemCallError] the worker is gone or didn't answer in time
|
|
196
|
+
def relay(action:, relay_id:, question_id:, read_timeout: RELAY_TIMEOUT, **extra)
|
|
197
|
+
post("relay", { action: action, relay_id: relay_id, question_id: question_id, **extra },
|
|
198
|
+
read_body: true, read_timeout: read_timeout)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# POST /session/:id/relay/status on a parent's worker: what its relay
|
|
202
|
+
# +relay_id+ holds (child_id, child_question_id, state, answer, by); 404
|
|
203
|
+
# for one it doesn't know. A POST with a body: routes match exact paths.
|
|
204
|
+
# @return [Response]
|
|
205
|
+
# @raise [SystemCallError]
|
|
206
|
+
def relay_status(relay_id, read_timeout: RELAY_STATUS_TIMEOUT)
|
|
207
|
+
post("relay/status", { relay_id: relay_id }, read_body: true, read_timeout: read_timeout)
|
|
208
|
+
end
|
|
209
|
+
|
|
183
210
|
# GET /session/:id/<path> as JSON.
|
|
184
211
|
# @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
|
|
185
212
|
def get_json(path)
|
|
@@ -283,11 +310,11 @@ module Samagotchi
|
|
|
283
310
|
# the same one.
|
|
284
311
|
def deadline = (Time.now.to_f + (@read_timeout * DEADLINE_SHARE)).round(3)
|
|
285
312
|
|
|
286
|
-
def post(path, payload, read_body:)
|
|
313
|
+
def post(path, payload, read_body:, read_timeout: @read_timeout)
|
|
287
314
|
sock = TCPSocket.new(@host, @port)
|
|
288
315
|
json_body = JSON.generate(payload)
|
|
289
316
|
sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
|
|
290
|
-
reply = read_reply(sock, path, whole: read_body)
|
|
317
|
+
reply = read_reply(sock, path, whole: read_body, timeout: read_timeout)
|
|
291
318
|
body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
|
|
292
319
|
Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
|
|
293
320
|
ensure
|
|
@@ -295,17 +322,17 @@ module Samagotchi
|
|
|
295
322
|
end
|
|
296
323
|
|
|
297
324
|
# The reply up to the Bridge's close (or its first line only), within
|
|
298
|
-
# @read_timeout
|
|
325
|
+
# +timeout+ seconds in all (@read_timeout by default).
|
|
299
326
|
# @raise [Errno::ETIMEDOUT]
|
|
300
|
-
def read_reply(sock, path, whole: true)
|
|
301
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
327
|
+
def read_reply(sock, path, whole: true, timeout: @read_timeout)
|
|
328
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
302
329
|
reply = +""
|
|
303
330
|
loop do
|
|
304
331
|
break if !whole && reply.include?("\n")
|
|
305
332
|
|
|
306
333
|
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
307
334
|
unless left.positive? && sock.wait_readable(left)
|
|
308
|
-
raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{
|
|
335
|
+
raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{timeout}s"
|
|
309
336
|
end
|
|
310
337
|
|
|
311
338
|
reply << sock.readpartial(16_384)
|
|
@@ -36,7 +36,7 @@ module Samagotchi
|
|
|
36
36
|
build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
|
|
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
|
-
FILES... optional allowlist of *.md basenames to include (default: all).
|
|
39
|
+
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
|
|
40
40
|
TEXT
|
|
41
41
|
|
|
42
42
|
# Each subcommand's --help (and only --help: -h and help are taken as
|
|
@@ -57,7 +57,7 @@ module Samagotchi
|
|
|
57
57
|
build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
|
|
58
58
|
Build local memories and installed hooks into a shareable bundle (dir or zip).
|
|
59
59
|
--scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
|
|
60
|
-
FILES... optional allowlist of *.md basenames to include (default: all).
|
|
60
|
+
FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
|
|
61
61
|
|
|
62
62
|
Examples:
|
|
63
63
|
chi bundle build --scope system
|
|
@@ -272,6 +272,7 @@ module Samagotchi
|
|
|
272
272
|
begin
|
|
273
273
|
uninstaller.run
|
|
274
274
|
@stdout.puts "Uninstalled bundle '#{bundle_name}'"
|
|
275
|
+
puts_trashed(uninstaller.trashed_files, uninstaller.trash_dir)
|
|
275
276
|
@stdout.puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
|
|
276
277
|
hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
|
|
277
278
|
@stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
|
|
@@ -311,9 +312,13 @@ module Samagotchi
|
|
|
311
312
|
if result.gone
|
|
312
313
|
@stdout.puts "Uninstalled bundle '#{name}'"
|
|
313
314
|
@stdout.puts "Removed: #{result.removed.join(", ")}" unless result.removed.empty?
|
|
315
|
+
result.trash.each_value { |files, dir| puts_trashed(files, dir) }
|
|
316
|
+
result.warnings.each { |w| @stdout.puts w }
|
|
314
317
|
return 0
|
|
315
318
|
end
|
|
316
319
|
@stdout.puts "Removed from #{name}: #{result.removed.join(", ")}" unless result.removed.empty?
|
|
320
|
+
result.trash.each_value { |files, dir| puts_trashed(files, dir) }
|
|
321
|
+
result.warnings.each { |w| @stdout.puts w }
|
|
317
322
|
result.blocked.each { |member, why| @stdout.puts "Kept #{member}: #{why}" }
|
|
318
323
|
@stderr.puts "Uninstall failed: #{name} stays installed until #{result.blocked.keys.join(", ")} goes " \
|
|
319
324
|
"(chi bundle uninstall #{name} --force)"
|
|
@@ -323,6 +328,11 @@ module Samagotchi
|
|
|
323
328
|
1
|
|
324
329
|
end
|
|
325
330
|
|
|
331
|
+
# Memory files an uninstall moved to the trash (MemoryBundle::Trash).
|
|
332
|
+
def puts_trashed(files, dir)
|
|
333
|
+
@stdout.puts "Moved to the trash: #{files.join(", ")} (#{dir})" if dir && !files.empty?
|
|
334
|
+
end
|
|
335
|
+
|
|
326
336
|
def status(rest)
|
|
327
337
|
name = rest.find { |a| !a.start_with?("--") }
|
|
328
338
|
require "json"
|
|
@@ -557,6 +567,7 @@ module Samagotchi
|
|
|
557
567
|
@stdout.puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
|
|
558
568
|
@stdout.puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
|
|
559
569
|
@stdout.puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
|
|
570
|
+
builder.warnings.each { |w| @stdout.puts w }
|
|
560
571
|
unless result[:placeholder_warnings].empty?
|
|
561
572
|
@stdout.puts "Placeholders:"
|
|
562
573
|
result[:placeholder_warnings].each { |w| @stdout.puts " #{w}" }
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.17.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
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
|
|
9
9
|
config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -16,7 +16,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
|
|
|
16
16
|
| `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
|
|
17
17
|
|
|
18
18
|
- The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
|
|
19
|
-
- `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description
|
|
19
|
+
- `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
|
|
20
20
|
|
|
21
21
|
## Tools you have
|
|
22
22
|
|
|
@@ -85,13 +85,13 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
85
85
|
|
|
86
86
|
| Command | Purpose |
|
|
87
87
|
|---------|---------|
|
|
88
|
-
| `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips. Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
|
|
88
|
+
| `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
|
|
89
89
|
| `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
|
|
90
|
-
| `uninstall <bundle> [--force]` |
|
|
90
|
+
| `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
|
|
91
91
|
| `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
|
|
92
92
|
| `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
|
|
93
93
|
| `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
|
|
94
|
-
| `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
|
|
94
|
+
| `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
|
|
95
95
|
|
|
96
96
|
**Scope resolution for install/build:**
|
|
97
97
|
- CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`Installer#resolve_target_dir`).
|
|
@@ -119,7 +119,7 @@ chi bundle build --scope system --out updated.zip
|
|
|
119
119
|
### Provenance internals (for debugging)
|
|
120
120
|
|
|
121
121
|
- Each installed bundle is recorded at `<system dir>/.bundles/<name>/manifest.json` + `bases/<file>.md` snapshots (`Provenance`). Used only for upgrade `Merger` and `status`/`diff`. Build does **not** write provenance.
|
|
122
|
-
- `index.md` is
|
|
122
|
+
- A bundle records only the files it wrote. `index.md` updates aren't fatal: a failed one is a warning in the install/uninstall output.
|
|
123
123
|
|
|
124
124
|
## Best practices for the agent
|
|
125
125
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
## Start here
|
|
4
4
|
- `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
|
|
5
5
|
memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
|
|
6
|
+
Its `model` row is this session's model (live after `/model`), `model key` my overlay key.
|
|
6
7
|
- My current session id is in the system prompt; resume with `chi --resume <id>`.
|
|
7
8
|
- My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
|
|
8
9
|
next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
- `delegate` hands a task to a child session that runs in parallel and returns only its final
|
|
26
27
|
reply (`delegate_result` waits for it; `session:` sends a follow-up to a child). A child is a
|
|
27
28
|
normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
|
|
28
|
-
with `chi --attach <id
|
|
29
|
+
with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
|
|
30
|
+
approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
|
|
29
31
|
- Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
|
|
30
32
|
commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
|
|
31
33
|
cards, side answers (`ask_model`),
|
|
@@ -41,7 +41,8 @@ module Samagotchi
|
|
|
41
41
|
used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
# @return [Integer] the budget, and the attempts spent this turn
|
|
45
|
+
attr_reader :limit, :attempts
|
|
45
46
|
|
|
46
47
|
def initialize(limit: self.class.limit)
|
|
47
48
|
@limit = limit
|