samagotchi 0.8.1 → 0.10.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 +89 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +9 -2
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/memory.md +5 -3
- data/docs/plugins.md +47 -17
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/identity.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/targets.rb +7 -1
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/log_subscriber.rb +5 -1
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +25 -30
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +29 -15
- data/lib/samagotchi/session_metrics.rb +2 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +8 -0
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +2 -13
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +13 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../events"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
class Bridge
|
|
5
7
|
# The last cards (Engine#show_card), hook notices (:hook_notice) and
|
|
@@ -25,9 +27,6 @@ module Samagotchi
|
|
|
25
27
|
# A failed turn leaves no prompt, so its cards stay before the next one.
|
|
26
28
|
class CardStore
|
|
27
29
|
CAPACITY = 20
|
|
28
|
-
# A failed turn's prompt goes back to the composer: no turn in the
|
|
29
|
-
# conversation.
|
|
30
|
-
TURN_ENDS = %i[turn_completed turn_canceled].freeze
|
|
31
30
|
|
|
32
31
|
def initialize(capacity: CAPACITY)
|
|
33
32
|
@capacity = capacity
|
|
@@ -79,7 +78,8 @@ module Samagotchi
|
|
|
79
78
|
when :turn_failed
|
|
80
79
|
@running = false
|
|
81
80
|
settle_during(after_turn: false)
|
|
82
|
-
|
|
81
|
+
# The turns that stay in the conversation.
|
|
82
|
+
when *Events::TURN_KEPT
|
|
83
83
|
@running = false
|
|
84
84
|
@turns_done += 1
|
|
85
85
|
settle_during(after_turn: true)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../atomic_file"
|
|
6
|
+
require_relative "../events"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
class Bridge
|
|
@@ -18,7 +19,6 @@ module Samagotchi
|
|
|
18
19
|
# writes it, nor does a card between turns.
|
|
19
20
|
class PendingCard
|
|
20
21
|
FILE = "pending_card.json"
|
|
21
|
-
TURN_ENDS = %i[turn_completed turn_canceled turn_failed].freeze
|
|
22
22
|
|
|
23
23
|
# @param session_dir [String] the session's folder
|
|
24
24
|
# @return [Hash, nil] {id:, bundle:}, nil without a readable file
|
|
@@ -58,7 +58,7 @@ module Samagotchi
|
|
|
58
58
|
def fold(event)
|
|
59
59
|
case event[:type]
|
|
60
60
|
when :card then card(event)
|
|
61
|
-
when *
|
|
61
|
+
when *Events::TURN_END then remove
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -46,18 +46,18 @@ module Samagotchi
|
|
|
46
46
|
# @param last_event_id [String, nil] reconnect cursor (or ?from_seq=):
|
|
47
47
|
# `<seq>-<epoch>` or a plain seq
|
|
48
48
|
# @param epoch [String, nil] this worker's epoch, in every frame id
|
|
49
|
-
# @param
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
49
|
+
# @param frame_provider [#call, nil] -> {snapshot: {messages:,
|
|
50
|
+
# current_turn:, queued:, event_seq:, ...}, session_state_snapshot:
|
|
51
|
+
# {status:, message_count:, ...}}; called with the event log held, so
|
|
52
|
+
# both describe the same event_seq (Bridge#snapshot_frame). Defaults
|
|
53
|
+
# to just the event_seq.
|
|
54
54
|
# @param join_with_snapshot [Boolean] start with a snapshot frame rather
|
|
55
55
|
# than a replay
|
|
56
56
|
# @param bridge [Samagotchi::Bridge, nil] for stop signalling
|
|
57
57
|
# @param max_queue [Integer] bounded-queue capacity
|
|
58
58
|
# @param heartbeat_interval [Float] idle-seconds between `: ping` frames
|
|
59
59
|
def initialize(engine:, ring:, session_id:, last_event_id: nil, epoch: nil,
|
|
60
|
-
|
|
60
|
+
frame_provider: nil, join_with_snapshot: false, bridge: nil,
|
|
61
61
|
max_queue: DEFAULT_MAX_QUEUE,
|
|
62
62
|
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
|
|
63
63
|
@engine = engine
|
|
@@ -65,8 +65,7 @@ module Samagotchi
|
|
|
65
65
|
@session_id = session_id
|
|
66
66
|
@last_event_id = last_event_id
|
|
67
67
|
@epoch = epoch
|
|
68
|
-
@
|
|
69
|
-
@turn_snapshot_provider = turn_snapshot_provider || -> { { event_seq: @engine.event_count } }
|
|
68
|
+
@frame_provider = frame_provider || -> { { snapshot: { event_seq: @engine.event_count }, session_state_snapshot: {} } }
|
|
70
69
|
@join_with_snapshot = join_with_snapshot
|
|
71
70
|
@bridge = bridge
|
|
72
71
|
@max_queue = max_queue
|
|
@@ -78,8 +77,6 @@ module Samagotchi
|
|
|
78
77
|
@foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
|
|
79
78
|
@high_water = @foreign_cursor ? 0 : cursor_seq
|
|
80
79
|
@handle = nil
|
|
81
|
-
@serving = false
|
|
82
|
-
@mutex = Monitor.new
|
|
83
80
|
end
|
|
84
81
|
|
|
85
82
|
# Non-blocking enqueue used as the per-connection live observer. Any
|
|
@@ -99,16 +96,15 @@ module Samagotchi
|
|
|
99
96
|
# Run the serve loop on the caller's (connection) thread. Blocks until
|
|
100
97
|
# the client disconnects or the bridge stops.
|
|
101
98
|
def serve!(io)
|
|
102
|
-
@serving = true
|
|
103
99
|
write_sse_headers(io)
|
|
104
100
|
begin
|
|
105
101
|
if @join_with_snapshot
|
|
106
102
|
# Subscribe and snapshot as one step of the event log, then go live.
|
|
107
|
-
|
|
103
|
+
frame = @engine.synchronize_events do
|
|
108
104
|
@handle = @engine.subscribe(observer: self)
|
|
109
105
|
take_snapshot
|
|
110
106
|
end
|
|
111
|
-
write_snapshot_frame(io, :snapshot,
|
|
107
|
+
write_snapshot_frame(io, :snapshot, frame)
|
|
112
108
|
else
|
|
113
109
|
# (1) subscribe the live queue FIRST.
|
|
114
110
|
@handle = @engine.subscribe(observer: self)
|
|
@@ -122,7 +118,6 @@ module Samagotchi
|
|
|
122
118
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
123
119
|
# Client hung up; nothing to do.
|
|
124
120
|
ensure
|
|
125
|
-
@serving = false
|
|
126
121
|
@engine.unsubscribe(handle: @handle) if @handle
|
|
127
122
|
end
|
|
128
123
|
end
|
|
@@ -222,23 +217,22 @@ module Samagotchi
|
|
|
222
217
|
write_snapshot_frame(io, :reset, @engine.synchronize_events { take_snapshot })
|
|
223
218
|
end
|
|
224
219
|
|
|
225
|
-
# With the event log held: the snapshot
|
|
226
|
-
#
|
|
227
|
-
#
|
|
220
|
+
# With the event log held: the snapshot frame's content (the snapshot
|
|
221
|
+
# and the session state, both as of one event_seq), and the high-water
|
|
222
|
+
# mark moved to that seq (down too, for a cursor from before a worker
|
|
223
|
+
# restart), so events after it pass #call and events it covers are
|
|
224
|
+
# skipped.
|
|
228
225
|
def take_snapshot
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
frame = @frame_provider.call
|
|
227
|
+
seq = frame[:snapshot][:event_seq].to_i
|
|
228
|
+
@high_water = seq
|
|
229
|
+
id = EventId.format(seq, @epoch)
|
|
230
|
+
{ snapshot: frame[:snapshot].merge(event_id: id),
|
|
231
|
+
session_state_snapshot: frame[:session_state_snapshot].merge(event_seq: seq, event_id: id) }
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
-
def write_snapshot_frame(io, type,
|
|
235
|
-
seq
|
|
236
|
-
state = @snapshot_provider.call.merge(event_seq: seq, event_id: snapshot[:event_id])
|
|
237
|
-
write_frame(io, seq: seq, data: { type: type, snapshot: snapshot, session_state_snapshot: state })
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def closed?
|
|
241
|
-
@mutex.synchronize { !@serving }
|
|
234
|
+
def write_snapshot_frame(io, type, frame)
|
|
235
|
+
write_frame(io, seq: frame[:snapshot][:event_seq], data: { type: type, **frame })
|
|
242
236
|
end
|
|
243
237
|
end
|
|
244
238
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../events"
|
|
3
4
|
require_relative "../kernel_loop"
|
|
4
5
|
require_relative "../steer"
|
|
5
6
|
require_relative "turn_notice"
|
|
@@ -15,12 +16,15 @@ module Samagotchi
|
|
|
15
16
|
# its terminal event, the same event that brings its messages into the
|
|
16
17
|
# session, so a snapshot shows a turn in exactly one of the two.
|
|
17
18
|
class TurnAccumulator
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# @param clock [#call] monotonic seconds: a tool call's duration
|
|
20
|
+
def initialize(max_output_chars: KernelLoop::DEFAULT_MAX_TOOL_OUTPUT_CHARS,
|
|
21
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
21
22
|
@max_output_chars = max_output_chars
|
|
23
|
+
@clock = clock
|
|
22
24
|
@mutex = Mutex.new
|
|
23
25
|
@turn = nil
|
|
26
|
+
# When each running tool call started, by [iteration, call_index].
|
|
27
|
+
@tool_started_at = {}
|
|
24
28
|
@queued = []
|
|
25
29
|
@merged_origins = []
|
|
26
30
|
@recap = nil
|
|
@@ -36,7 +40,8 @@ module Samagotchi
|
|
|
36
40
|
# @return [Hash, nil] a copy of the turn in progress: prompt, origin,
|
|
37
41
|
# continue, ordered parts (thinking / text / tool / input / steer /
|
|
38
42
|
# reminder / notice), pending_question and the last event_seq folded
|
|
39
|
-
# in. A
|
|
43
|
+
# in. A finished tool part keeps the live row's action and duration_ms
|
|
44
|
+
# too. A notice part holds the event of one of the turn's rows
|
|
40
45
|
# (TurnNotice: a hook's notice, an empty-answer retry, a question and
|
|
41
46
|
# its answer), which a UI replays through its live handler.
|
|
42
47
|
def current_turn
|
|
@@ -81,6 +86,80 @@ module Samagotchi
|
|
|
81
86
|
messages
|
|
82
87
|
end
|
|
83
88
|
|
|
89
|
+
# The turn in progress (#current_turn's shape, symbol keys) as the live
|
|
90
|
+
# events that would have drawn it, in order, as turn_events.js
|
|
91
|
+
# snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
|
|
92
|
+
# pins both): a generation_chunk per thinking or text part (a
|
|
93
|
+
# generation_completed closes text before the next step's), a tool's
|
|
94
|
+
# tool_call_started and, once finished, its tool_call_completed (with
|
|
95
|
+
# the live activity: action, tool, params, status), a merged_input
|
|
96
|
+
# (the one synthetic type) per merged prompt, a steer-only
|
|
97
|
+
# pending_input_merged per steer, a reminder_injected, and a notice
|
|
98
|
+
# part's own event.
|
|
99
|
+
# @param started_at [String, nil] the turn's start, on turn_started
|
|
100
|
+
# @return [Array<Hash>] symbol keys and types; [] with no turn
|
|
101
|
+
def self.replay_events(turn, started_at: nil)
|
|
102
|
+
return [] unless turn
|
|
103
|
+
|
|
104
|
+
started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
|
|
105
|
+
started[:started_at] = started_at if started_at
|
|
106
|
+
events = [with_images(started, turn[:images])]
|
|
107
|
+
text_iteration = nil # a text part is open for this iteration
|
|
108
|
+
close_text = lambda do
|
|
109
|
+
events << { type: :generation_completed } unless text_iteration.nil?
|
|
110
|
+
text_iteration = nil
|
|
111
|
+
end
|
|
112
|
+
Array(turn[:parts]).each do |part|
|
|
113
|
+
case part[:kind]
|
|
114
|
+
when "thinking" then events << { type: :generation_chunk, text: "", thinking: part[:text], iteration: part[:iteration] }
|
|
115
|
+
when "text"
|
|
116
|
+
close_text.call if !text_iteration.nil? && text_iteration != part[:iteration]
|
|
117
|
+
text_iteration = part[:iteration]
|
|
118
|
+
events << { type: :generation_chunk, text: part[:text], thinking: "", iteration: part[:iteration] }
|
|
119
|
+
when "tool"
|
|
120
|
+
close_text.call
|
|
121
|
+
events.concat(tool_replay_events(part))
|
|
122
|
+
when "input"
|
|
123
|
+
close_text.call
|
|
124
|
+
events << { type: :merged_input, content: part[:text], origins: part[:origins] || [] }
|
|
125
|
+
when "reminder"
|
|
126
|
+
close_text.call
|
|
127
|
+
events << { type: :reminder_injected, reminders: part[:reminders] || [] }
|
|
128
|
+
when "notice"
|
|
129
|
+
close_text.call
|
|
130
|
+
notice = part[:event]
|
|
131
|
+
events << notice.merge(type: notice[:type].to_sym) if notice.is_a?(Hash) && notice[:type]
|
|
132
|
+
when "steer"
|
|
133
|
+
close_text.call
|
|
134
|
+
events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
events
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.tool_replay_events(part)
|
|
141
|
+
call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
|
|
142
|
+
call[:label] = part[:label] if part[:label]
|
|
143
|
+
title = part[:title] ? { title: part[:title] } : {}
|
|
144
|
+
events = [{ type: :tool_call_started, **call, params: part[:params], **title }]
|
|
145
|
+
return events if part[:status] == "running"
|
|
146
|
+
|
|
147
|
+
activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
|
|
148
|
+
activity[:action] = part[:action] if part[:action]
|
|
149
|
+
completed = { type: :tool_call_completed, **call, output: part[:output], output_truncated: !!part[:output_truncated],
|
|
150
|
+
activity: activity }
|
|
151
|
+
completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
|
|
152
|
+
completed[:diff] = part[:diff] if part[:diff]
|
|
153
|
+
events << with_images(completed, part[:images])
|
|
154
|
+
end
|
|
155
|
+
private_class_method :tool_replay_events
|
|
156
|
+
|
|
157
|
+
# +event+ with images: when there are any.
|
|
158
|
+
def self.with_images(event, images)
|
|
159
|
+
Array(images).empty? ? event : event.merge(images: images)
|
|
160
|
+
end
|
|
161
|
+
private_class_method :with_images
|
|
162
|
+
|
|
84
163
|
# @return [String, nil] the last idle recap, until a turn makes it stale
|
|
85
164
|
def recap
|
|
86
165
|
@mutex.synchronize { @recap }
|
|
@@ -119,6 +198,7 @@ module Samagotchi
|
|
|
119
198
|
dequeue([event[:origin]])
|
|
120
199
|
@turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
|
|
121
200
|
parts: [], pending_question: nil }
|
|
201
|
+
@tool_started_at = {}
|
|
122
202
|
@turn[:images] = event[:images] if event[:images]
|
|
123
203
|
when :input_merged
|
|
124
204
|
@merged_origins = Array(event[:origins])
|
|
@@ -127,7 +207,7 @@ module Samagotchi
|
|
|
127
207
|
return unless @turn
|
|
128
208
|
|
|
129
209
|
fold_turn_event(event)
|
|
130
|
-
if
|
|
210
|
+
if Events::TURN_END.include?(type)
|
|
131
211
|
@turn = nil
|
|
132
212
|
@merged_origins = []
|
|
133
213
|
else
|
|
@@ -152,6 +232,7 @@ module Samagotchi
|
|
|
152
232
|
part[:label] = event[:label] if event[:label]
|
|
153
233
|
part[:title] = event[:title] if event[:title]
|
|
154
234
|
parts << part
|
|
235
|
+
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
155
236
|
when :tool_call_completed
|
|
156
237
|
tool = parts.reverse_each.find do |part|
|
|
157
238
|
part[:kind] == "tool" && part[:iteration] == event[:iteration] && part[:call_index] == event[:call_index]
|
|
@@ -165,6 +246,12 @@ module Samagotchi
|
|
|
165
246
|
tool[:output_truncated] = capped || !!event[:output_truncated]
|
|
166
247
|
tool[:images] = event[:images] if event[:images]
|
|
167
248
|
tool[:diff] = event[:diff] if event[:diff]
|
|
249
|
+
action = event.dig(:activity, :action)
|
|
250
|
+
tool[:action] = action.to_s if action
|
|
251
|
+
# As the live row times it (EventRenderer): from tool_call_started,
|
|
252
|
+
# less an approval wait.
|
|
253
|
+
started_at = @tool_started_at.delete([event[:iteration], event[:call_index]])
|
|
254
|
+
tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round if started_at
|
|
168
255
|
when :pending_input_merged
|
|
169
256
|
# A steer-only merge (count 0) has no user part: the origins stay
|
|
170
257
|
# for the user lines' own merge.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -51,7 +51,6 @@ module Samagotchi
|
|
|
51
51
|
REQUEST_GRACE_SECONDS = 1.0
|
|
52
52
|
# The largest request body read (images travel as refs, never bytes).
|
|
53
53
|
MAX_BODY_BYTES = 1_000_000
|
|
54
|
-
MAX_TURN_IMAGES = 20
|
|
55
54
|
# A request's deadline (see #handle_post_turn) that isn't epoch seconds.
|
|
56
55
|
BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
|
|
57
56
|
|
|
@@ -227,6 +226,18 @@ module Samagotchi
|
|
|
227
226
|
end
|
|
228
227
|
end
|
|
229
228
|
|
|
229
|
+
# The snapshot and the session state (status, pending question, ...) as
|
|
230
|
+
# one step of the event log, both as of the same event_seq: what a
|
|
231
|
+
# snapshot or reset frame (SSEWriter) and GET snapshot carry.
|
|
232
|
+
# @return [Hash] {snapshot:, session_state_snapshot:}
|
|
233
|
+
def snapshot_frame
|
|
234
|
+
@engine.synchronize_events do
|
|
235
|
+
snap = snapshot
|
|
236
|
+
state = @engine.session_state_snapshot.merge(event_seq: snap[:event_seq], event_id: snap[:event_id])
|
|
237
|
+
{ snapshot: snap, session_state_snapshot: state }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
230
241
|
private
|
|
231
242
|
|
|
232
243
|
# The Engine's commands: the built-ins and its plugins'.
|
|
@@ -320,7 +331,7 @@ module Samagotchi
|
|
|
320
331
|
client_id: stream_client_id(request[:query]))
|
|
321
332
|
break # SSE owns the connection until the client disconnects.
|
|
322
333
|
else
|
|
323
|
-
payload, status, body =
|
|
334
|
+
payload, status, body = dispatch(route.first, route.last, request[:body])
|
|
324
335
|
write_json(io, status, payload, body)
|
|
325
336
|
end
|
|
326
337
|
|
|
@@ -356,8 +367,7 @@ module Samagotchi
|
|
|
356
367
|
session_id: @session_id,
|
|
357
368
|
last_event_id: last_event_id,
|
|
358
369
|
epoch: @epoch,
|
|
359
|
-
|
|
360
|
-
turn_snapshot_provider: -> { self.snapshot },
|
|
370
|
+
frame_provider: -> { snapshot_frame },
|
|
361
371
|
# A reconnect (with a cursor) replays; only a fresh join snapshots.
|
|
362
372
|
join_with_snapshot: snapshot && last_event_id.nil?,
|
|
363
373
|
bridge: self,
|
|
@@ -430,28 +440,33 @@ module Samagotchi
|
|
|
430
440
|
[handler, m[1]]
|
|
431
441
|
end
|
|
432
442
|
|
|
443
|
+
# Every route but the stream: only this bridge's own session (see
|
|
444
|
+
# #own_session?), and a handler that raises answers 500 bridge_error,
|
|
445
|
+
# logged (the connection thread doesn't report).
|
|
446
|
+
# @return [Array(Hash, Integer, Hash)] headers, status, body
|
|
447
|
+
def dispatch(handler, session_id, body)
|
|
448
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
449
|
+
|
|
450
|
+
send(handler, session_id, body)
|
|
451
|
+
rescue StandardError => e
|
|
452
|
+
Log.exception(:bridge, "handler_failed", e)
|
|
453
|
+
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
454
|
+
end
|
|
455
|
+
|
|
433
456
|
# /recap in an attached TUI: the saved recap, and a new one asked for at
|
|
434
457
|
# once (it arrives as :recap_ready). Answers mid-turn too.
|
|
435
458
|
# 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
|
|
436
459
|
def handle_recap(session_id, _body = nil)
|
|
437
|
-
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
438
|
-
|
|
439
460
|
recap = @engine.recap
|
|
440
461
|
return [{}, 200, { enabled: false }] unless recap
|
|
441
462
|
|
|
442
463
|
saved = @engine.saved_recap
|
|
443
464
|
[{}, 200, { enabled: true, saved: saved, request: @engine.request_recap.to_s, min_user_turns: recap.min_user_turns }]
|
|
444
|
-
rescue StandardError => e
|
|
445
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
446
465
|
end
|
|
447
466
|
|
|
448
467
|
# Cancel the active turn on this session's engine, if any.
|
|
449
468
|
# Returns [headers, status, body].
|
|
450
469
|
def handle_cancel(session_id, body)
|
|
451
|
-
unless own_session?(session_id)
|
|
452
|
-
return [{}, 404, { error: "unknown_session" }]
|
|
453
|
-
end
|
|
454
|
-
|
|
455
470
|
# Optional reason from JSON body
|
|
456
471
|
reason = :manual
|
|
457
472
|
if body && !body.strip.empty?
|
|
@@ -468,31 +483,19 @@ module Samagotchi
|
|
|
468
483
|
else
|
|
469
484
|
[{}, 409, { error: "not_running", detail: "no active turn to cancel", session_id: @session_id }]
|
|
470
485
|
end
|
|
471
|
-
rescue StandardError => e
|
|
472
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
473
486
|
end
|
|
474
487
|
|
|
475
488
|
# Answer the pending question. A past +deadline+ (see #handle_post_turn):
|
|
476
489
|
# 408 deadline_passed, and the question stays open. An answer takes no
|
|
477
490
|
# event hold, so it is checked right before it is recorded.
|
|
478
491
|
def handle_answer(session_id, body)
|
|
479
|
-
unless own_session?(session_id)
|
|
480
|
-
return [{}, 404, { error: "unknown_session" }]
|
|
481
|
-
end
|
|
482
492
|
parsed = parse_json(body)
|
|
483
493
|
unless parsed.is_a?(Hash)
|
|
484
494
|
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
|
|
485
495
|
end
|
|
486
|
-
qid = fetched(parsed, "id")
|
|
487
|
-
selected = fetched(parsed, "selected")
|
|
488
|
-
freeform = fetched(parsed, "freeform")
|
|
489
|
-
# Support nested answer
|
|
490
|
-
if parsed["answer"].is_a?(Hash)
|
|
491
|
-
ans = parsed["answer"]
|
|
492
|
-
qid ||= fetched(ans, "id")
|
|
493
|
-
selected ||= fetched(ans, "selected")
|
|
494
|
-
freeform ||= fetched(ans, "freeform")
|
|
495
|
-
end
|
|
496
|
+
qid = fetched(parsed, "id")
|
|
497
|
+
selected = fetched(parsed, "selected")
|
|
498
|
+
freeform = fetched(parsed, "freeform")
|
|
496
499
|
if qid.to_s.strip.empty?
|
|
497
500
|
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
|
|
498
501
|
end
|
|
@@ -508,8 +511,6 @@ module Samagotchi
|
|
|
508
511
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
509
512
|
rescue ArgumentError => e
|
|
510
513
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
511
|
-
rescue StandardError => e
|
|
512
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
513
514
|
end
|
|
514
515
|
end
|
|
515
516
|
|
|
@@ -520,8 +521,6 @@ module Samagotchi
|
|
|
520
521
|
# +deadline+ (dismissing an approval denies it): 408, as for an answer.
|
|
521
522
|
# Returns [headers, status, body].
|
|
522
523
|
def handle_dismiss_question(session_id, body)
|
|
523
|
-
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
524
|
-
|
|
525
524
|
parsed = parse_json(body)
|
|
526
525
|
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
527
526
|
|
|
@@ -536,8 +535,6 @@ module Samagotchi
|
|
|
536
535
|
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
|
|
537
536
|
|
|
538
537
|
[{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
|
|
539
|
-
rescue StandardError => e
|
|
540
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
541
538
|
end
|
|
542
539
|
|
|
543
540
|
# Queue a session command (/model, /models, !rollback, !cmd, /continue)
|
|
@@ -550,7 +547,6 @@ module Samagotchi
|
|
|
550
547
|
# read-only ones (/models) too: its client said it didn't run.
|
|
551
548
|
# Returns [headers, status, body].
|
|
552
549
|
def handle_command(session_id, body)
|
|
553
|
-
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
554
550
|
return [{}, 501, { error: "commands_unavailable" }] unless @on_command
|
|
555
551
|
|
|
556
552
|
parsed = parse_json(body)
|
|
@@ -584,8 +580,6 @@ module Samagotchi
|
|
|
584
580
|
return deadline_passed("command") unless queued
|
|
585
581
|
|
|
586
582
|
[{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
|
|
587
|
-
rescue StandardError => e
|
|
588
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
589
583
|
end
|
|
590
584
|
|
|
591
585
|
# A client asks the worker to exit now (`/exit` in the attached TUI). The
|
|
@@ -594,7 +588,6 @@ module Samagotchi
|
|
|
594
588
|
# 200 {status: "exiting", discard?: the session is empty and goes}, or 409 {status: "held", reason:} naming what
|
|
595
589
|
# keeps it up. Returns [headers, status, body].
|
|
596
590
|
def handle_exit_request(session_id, body)
|
|
597
|
-
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
598
591
|
return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
|
|
599
592
|
|
|
600
593
|
parsed = parse_json(body)
|
|
@@ -610,11 +603,12 @@ module Samagotchi
|
|
|
610
603
|
end
|
|
611
604
|
|
|
612
605
|
[{}, 409, { status: "held", reason: reason.to_s, session_id: @session_id }]
|
|
613
|
-
rescue StandardError => e
|
|
614
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
615
606
|
end
|
|
616
607
|
|
|
617
|
-
#
|
|
608
|
+
# Queue a turn for this session through its file IPC (the worker polls
|
|
609
|
+
# its input dir). Returns [headers, status, body]. Only this bridge's own
|
|
610
|
+
# session: another id, in the path or the body, is refused as on every
|
|
611
|
+
# other route.
|
|
618
612
|
#
|
|
619
613
|
# A +deadline+ (wall-clock epoch seconds; the client shares this machine's
|
|
620
614
|
# clock) is when the client stops waiting (BridgeClient#post_turn): a turn
|
|
@@ -638,56 +632,40 @@ module Samagotchi
|
|
|
638
632
|
return [{ "Allow" => "POST" }, 400,
|
|
639
633
|
{ error: "missing_fields", detail: "session_id and prompt are required" }]
|
|
640
634
|
end
|
|
641
|
-
|
|
642
|
-
# sessions dir (turn_images and enqueue_turn build paths from it).
|
|
643
|
-
return [{}, 400, { error: "invalid_session_id" }] unless Session.valid_id?(sid)
|
|
635
|
+
return [{}, 404, { error: "unknown_session" }] unless own_session?(sid)
|
|
644
636
|
|
|
645
|
-
images =
|
|
637
|
+
images = ImageStore.check_refs(session_dir, fetched(parsed, "images"))
|
|
646
638
|
return [{}, 400, { error: "bad_images", detail: images }] if images.is_a?(String)
|
|
647
639
|
|
|
648
640
|
deadline = fetched(parsed, "deadline")
|
|
649
641
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
650
642
|
|
|
651
643
|
enqueued_id = SecureRandom.uuid
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
@engine.announce(enqueued_event)
|
|
667
|
-
@on_input&.call
|
|
668
|
-
end
|
|
669
|
-
end
|
|
670
|
-
elsif expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
671
|
-
:expired
|
|
672
|
-
else
|
|
673
|
-
enqueue_turn(session_id: sid, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
674
|
-
no_interrupt: no_interrupt, images: images)
|
|
644
|
+
# Write and announce with the event log held: the worker can't emit
|
|
645
|
+
# this turn's :turn_started (or merge it mid-turn) before
|
|
646
|
+
# :turn_enqueued, and a failed write announces nothing.
|
|
647
|
+
enqueued = @engine.synchronize_events do
|
|
648
|
+
next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
649
|
+
|
|
650
|
+
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
651
|
+
no_interrupt: no_interrupt, images: images).tap do |ok|
|
|
652
|
+
next unless ok
|
|
653
|
+
|
|
654
|
+
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
655
|
+
enqueued_event[:images] = images unless images.empty?
|
|
656
|
+
@engine.announce(enqueued_event)
|
|
657
|
+
@on_input&.call
|
|
675
658
|
end
|
|
659
|
+
end
|
|
676
660
|
return deadline_passed("turn") if enqueued == :expired
|
|
677
661
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
678
662
|
|
|
679
|
-
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id:
|
|
680
|
-
rescue StandardError => e
|
|
681
|
-
[{}, 500, { error: "bridge_error", detail: e.message }]
|
|
663
|
+
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
|
|
682
664
|
end
|
|
683
665
|
|
|
684
666
|
# Read-only snapshot surface. AC #4: too-old reconnects re-derive state
|
|
685
667
|
# from here.
|
|
686
668
|
def handle_state(session_id, _body = nil)
|
|
687
|
-
unless own_session?(session_id)
|
|
688
|
-
return [{}, 404, { error: "unknown_session" }]
|
|
689
|
-
end
|
|
690
|
-
|
|
691
669
|
state = @engine.session_state_snapshot
|
|
692
670
|
[{}, 200, { session_id: @session_id, session_state_snapshot: state.merge(event_id: event_id(state[:event_seq])) }]
|
|
693
671
|
end
|
|
@@ -696,8 +674,6 @@ module Samagotchi
|
|
|
696
674
|
# profile asked from the server when no turn has reported them yet (so,
|
|
697
675
|
# unlike /state, it may wait on one short /props GET).
|
|
698
676
|
def handle_stats(session_id, _body = nil)
|
|
699
|
-
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
700
|
-
|
|
701
677
|
[{}, 200, { session_id: @session_id, metrics: @engine.stats_snapshot }]
|
|
702
678
|
end
|
|
703
679
|
|
|
@@ -706,14 +682,7 @@ module Samagotchi
|
|
|
706
682
|
# streams from the snapshot's event_seq, and the ring replays what came
|
|
707
683
|
# after (or the stream resets). Returns [headers, status, body].
|
|
708
684
|
def handle_snapshot(session_id, _body = nil)
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
body = @engine.synchronize_events do
|
|
712
|
-
snap = snapshot
|
|
713
|
-
state = @engine.session_state_snapshot.merge(event_seq: snap[:event_seq], event_id: snap[:event_id])
|
|
714
|
-
{ snapshot: snap, session_state_snapshot: state }
|
|
715
|
-
end
|
|
716
|
-
[{}, 200, body]
|
|
685
|
+
[{}, 200, snapshot_frame]
|
|
717
686
|
end
|
|
718
687
|
|
|
719
688
|
# The stream cursor for +seq+ in this worker.
|
|
@@ -722,10 +691,9 @@ module Samagotchi
|
|
|
722
691
|
end
|
|
723
692
|
|
|
724
693
|
# A per-session bridge only ever owns one Engine (for @session_id). The
|
|
725
|
-
# stream and
|
|
726
|
-
# serving a different id's data (or
|
|
727
|
-
#
|
|
728
|
-
# another worker's input dir) through SessionInbox.write_input.
|
|
694
|
+
# stream, state and turn surfaces must serve that session and nothing
|
|
695
|
+
# else — serving a different id's data (or writing into another
|
|
696
|
+
# session's input dir) would be a cross-session leak.
|
|
729
697
|
def own_session?(session_id)
|
|
730
698
|
session_id.to_s == @session_id.to_s
|
|
731
699
|
end
|
|
@@ -751,34 +719,13 @@ module Samagotchi
|
|
|
751
719
|
[{}, 408, { error: "deadline_passed", detail: "the #{what} arrived after its client stopped waiting; not run" }]
|
|
752
720
|
end
|
|
753
721
|
|
|
754
|
-
# Write a turn into
|
|
755
|
-
#
|
|
756
|
-
def enqueue_turn(
|
|
757
|
-
session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
|
|
722
|
+
# Write a turn into this session's input dir, reusing the file IPC the
|
|
723
|
+
# worker polls. Never calls run_turn across the boundary.
|
|
724
|
+
def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
|
|
758
725
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
759
726
|
no_interrupt: no_interrupt, images: images)
|
|
760
727
|
end
|
|
761
728
|
|
|
762
|
-
# A turn's images as [{file:, name:}], or a String saying what's wrong.
|
|
763
|
-
# Only refs to files already in that session's images/ pass (a web
|
|
764
|
-
# upload): never a path, so no client can make the worker read a file.
|
|
765
|
-
def turn_images(session_id, raw)
|
|
766
|
-
return [] if raw.nil?
|
|
767
|
-
return "images must be a list" unless raw.is_a?(Array)
|
|
768
|
-
return "at most #{MAX_TURN_IMAGES} images" if raw.size > MAX_TURN_IMAGES
|
|
769
|
-
|
|
770
|
-
session_dir = Session.session_dir(session_id, state_dir: @state_dir || Session.default_state_dir)
|
|
771
|
-
raw.map do |image|
|
|
772
|
-
return "each image must be {file:, name:}" unless image.is_a?(Hash)
|
|
773
|
-
|
|
774
|
-
ref = ImageStore.symbolize(image)
|
|
775
|
-
return "images are refs to uploaded files, not paths" if ref.key?(:path)
|
|
776
|
-
return "unknown image #{ref[:file].to_s[0, 80]}" unless ImageStore.valid_ref?(session_dir, ref)
|
|
777
|
-
|
|
778
|
-
{ file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
|
|
779
|
-
end
|
|
780
|
-
end
|
|
781
|
-
|
|
782
729
|
# ── HTTP plumbing ────────────────────────────────────────────────────────
|
|
783
730
|
|
|
784
731
|
def read_request(io)
|