samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "event_id"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
class Bridge
|
|
9
|
+
# Per-connection SSE writer.
|
|
10
|
+
#
|
|
11
|
+
# This object doubles as the per-connection live observer wired into the
|
|
12
|
+
# Engine via `Engine#subscribe`: its `#call` is invoked synchronously from
|
|
13
|
+
# `Engine#emit_event` inside the turn thread, so it must be strictly
|
|
14
|
+
# non-blocking — it only enqueues onto a bounded queue and returns. A
|
|
15
|
+
# dedicated writer thread (the connection's own thread) drains the queue
|
|
16
|
+
# and serialises SSE frames onto the socket, so a slow / hung client can
|
|
17
|
+
# never stall the running turn or other subscribers.
|
|
18
|
+
#
|
|
19
|
+
# #serve! performs the replay wiring in the correctness-ordered sequence
|
|
20
|
+
# mandated by the bridge design invariants:
|
|
21
|
+
# 1. subscribe the live queue FIRST
|
|
22
|
+
# 2. capture `event_count` (snapshot)
|
|
23
|
+
# 3. replay buffered `seq ∈ (from_seq, snapshot]`
|
|
24
|
+
# 4. drain the live queue for `seq > snapshot`
|
|
25
|
+
# which guarantees no gap and no overlap; a per-connection `event_seq`
|
|
26
|
+
# high-water mark de-duplicates the subscribe→capture window.
|
|
27
|
+
#
|
|
28
|
+
# A client joining with `?snapshot=1` (and no cursor) instead gets one
|
|
29
|
+
# `snapshot` frame: the session's messages, the turn in progress and the
|
|
30
|
+
# event_seq they cover, taken with the event log held, together with the
|
|
31
|
+
# subscribe. Live events follow from the next seq. A `reset` frame (the
|
|
32
|
+
# cursor can't be replayed) carries the same snapshot, numbered with its
|
|
33
|
+
# own seq.
|
|
34
|
+
#
|
|
35
|
+
# Frame ids are `<seq>-<epoch>`: event_seq starts over in each worker, so
|
|
36
|
+
# the epoch (one per Bridge, so per worker) tells whose seq a cursor is.
|
|
37
|
+
# A cursor from another epoch gets a `reset` frame. A plain numeric
|
|
38
|
+
# cursor (a client that keeps event_seq itself) replays as before.
|
|
39
|
+
class SSEWriter
|
|
40
|
+
DEFAULT_MAX_QUEUE = 1024
|
|
41
|
+
DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
|
42
|
+
|
|
43
|
+
# @param engine [Samagotchi::Engine] the owning engine (live fan-out)
|
|
44
|
+
# @param ring [Samagotchi::Bridge::RingBuffer] shared capture buffer
|
|
45
|
+
# @param session_id [String]
|
|
46
|
+
# @param last_event_id [String, nil] reconnect cursor (or ?from_seq=):
|
|
47
|
+
# `<seq>-<epoch>` or a plain seq
|
|
48
|
+
# @param epoch [String, nil] this worker's epoch, in every frame id
|
|
49
|
+
# @param snapshot_provider [#call] -> {status:, message_count:,
|
|
50
|
+
# last_prompt:, event_seq:} (Engine#session_state_snapshot)
|
|
51
|
+
# @param turn_snapshot_provider [#call, nil] -> {messages:, current_turn:,
|
|
52
|
+
# queued:, event_seq:}; called with the event log held
|
|
53
|
+
# (Bridge#snapshot). Defaults to just the event_seq.
|
|
54
|
+
# @param join_with_snapshot [Boolean] start with a snapshot frame rather
|
|
55
|
+
# than a replay
|
|
56
|
+
# @param bridge [Samagotchi::Bridge, nil] for stop signalling
|
|
57
|
+
# @param max_queue [Integer] bounded-queue capacity
|
|
58
|
+
# @param heartbeat_interval [Float] idle-seconds between `: ping` frames
|
|
59
|
+
def initialize(engine:, ring:, session_id:, last_event_id: nil, epoch: nil,
|
|
60
|
+
snapshot_provider:, turn_snapshot_provider: nil, join_with_snapshot: false, bridge: nil,
|
|
61
|
+
max_queue: DEFAULT_MAX_QUEUE,
|
|
62
|
+
heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
|
|
63
|
+
@engine = engine
|
|
64
|
+
@ring = ring
|
|
65
|
+
@session_id = session_id
|
|
66
|
+
@last_event_id = last_event_id
|
|
67
|
+
@epoch = epoch
|
|
68
|
+
@snapshot_provider = snapshot_provider
|
|
69
|
+
@turn_snapshot_provider = turn_snapshot_provider || -> { { event_seq: @engine.event_count } }
|
|
70
|
+
@join_with_snapshot = join_with_snapshot
|
|
71
|
+
@bridge = bridge
|
|
72
|
+
@max_queue = max_queue
|
|
73
|
+
@heartbeat_interval = heartbeat_interval
|
|
74
|
+
|
|
75
|
+
@queue = BoundedQueue.new(capacity: @max_queue)
|
|
76
|
+
cursor_seq, cursor_epoch = EventId.parse(@last_event_id)
|
|
77
|
+
# A foreign cursor's seq means nothing here; its reset sets the mark.
|
|
78
|
+
@foreign_cursor = !cursor_epoch.nil? && cursor_epoch != @epoch
|
|
79
|
+
@high_water = @foreign_cursor ? 0 : cursor_seq
|
|
80
|
+
@handle = nil
|
|
81
|
+
@serving = false
|
|
82
|
+
@mutex = Monitor.new
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Non-blocking enqueue used as the per-connection live observer. Any
|
|
86
|
+
# caller runs in the turn thread; must never block. Events already
|
|
87
|
+
# served (seq <= high water) are dropped to avoid replay overlap.
|
|
88
|
+
def call(payload)
|
|
89
|
+
seq = payload[:event_seq]
|
|
90
|
+
return if seq && seq <= @high_water
|
|
91
|
+
|
|
92
|
+
@queue.push(payload)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
# Enqueue isolation is best-effort; a bad payload must not break the
|
|
95
|
+
# running turn.
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Run the serve loop on the caller's (connection) thread. Blocks until
|
|
100
|
+
# the client disconnects or the bridge stops.
|
|
101
|
+
def serve!(io)
|
|
102
|
+
@serving = true
|
|
103
|
+
write_sse_headers(io)
|
|
104
|
+
begin
|
|
105
|
+
if @join_with_snapshot
|
|
106
|
+
# Subscribe and snapshot as one step of the event log, then go live.
|
|
107
|
+
snapshot = @engine.synchronize_events do
|
|
108
|
+
@handle = @engine.subscribe(observer: self)
|
|
109
|
+
take_snapshot
|
|
110
|
+
end
|
|
111
|
+
write_snapshot_frame(io, :snapshot, snapshot)
|
|
112
|
+
else
|
|
113
|
+
# (1) subscribe the live queue FIRST.
|
|
114
|
+
@handle = @engine.subscribe(observer: self)
|
|
115
|
+
# (2) capture the snapshot sequence.
|
|
116
|
+
snapshot_seq = @engine.event_count
|
|
117
|
+
# (3) replay buffered (from_seq, snapshot].
|
|
118
|
+
replay!(io, snapshot_seq)
|
|
119
|
+
end
|
|
120
|
+
# (4) drain live events for seq > snapshot.
|
|
121
|
+
drain!(io)
|
|
122
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
123
|
+
# Client hung up; nothing to do.
|
|
124
|
+
ensure
|
|
125
|
+
@serving = false
|
|
126
|
+
@engine.unsubscribe(handle: @handle) if @handle
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def replay!(io, snapshot_seq)
|
|
133
|
+
from_seq = @high_water
|
|
134
|
+
oldest = @ring.oldest_seq
|
|
135
|
+
if @foreign_cursor || from_seq > snapshot_seq || (from_seq.positive? && oldest && from_seq < oldest - 1)
|
|
136
|
+
# Cursor is from another worker (epoch), ahead of the engine (a
|
|
137
|
+
# worker restarted, plain cursor) or behind the
|
|
138
|
+
# ring's buffered window (overflow): the server cannot replay the
|
|
139
|
+
# gap, so it emits a reset marker and the client re-syncs. A cursor
|
|
140
|
+
# at oldest - 1 has seen everything before the window, so it
|
|
141
|
+
# replays. A cold
|
|
142
|
+
# connect (from_seq == 0) replays the whole served window, and a
|
|
143
|
+
# fully caught-up cursor (from_seq == snapshot_seq) is NOT a reset
|
|
144
|
+
# case — it gets an empty replay and holds for live events.
|
|
145
|
+
emit_reset(io)
|
|
146
|
+
else
|
|
147
|
+
@ring.events_in_range(after_seq: from_seq, to_seq: snapshot_seq).each { |record| write_event(io, record[:data]) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def drain!(io)
|
|
152
|
+
loop do
|
|
153
|
+
payload = @queue.pop(@heartbeat_interval)
|
|
154
|
+
if payload
|
|
155
|
+
if payload.is_a?(Hash) && payload[:sse_reset]
|
|
156
|
+
emit_reset(io)
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
write_event(io, payload)
|
|
161
|
+
# A fresh overflow since the last drain => the client fell too far
|
|
162
|
+
# behind; nudge a reconnect rather than flooding more events.
|
|
163
|
+
emit_reset(io) if @queue.overflow_dropped?
|
|
164
|
+
@queue.clear_overflow!
|
|
165
|
+
elsif @bridge&.stopped?
|
|
166
|
+
break
|
|
167
|
+
else
|
|
168
|
+
write_heartbeat(io)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def write_event(io, event)
|
|
176
|
+
seq = event[:event_seq]
|
|
177
|
+
return if seq && seq <= @high_water
|
|
178
|
+
|
|
179
|
+
write_frame(io, seq: seq, data: event)
|
|
180
|
+
@high_water = seq if seq && seq > @high_water
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write_sse_headers(io)
|
|
184
|
+
io.write(
|
|
185
|
+
"HTTP/1.1 200 OK\r\n" \
|
|
186
|
+
"Content-Type: text/event-stream\r\n" \
|
|
187
|
+
"Cache-Control: no-cache\r\n" \
|
|
188
|
+
"Connection: keep-alive\r\n" \
|
|
189
|
+
"X-Accel-Buffering: no\r\n" \
|
|
190
|
+
"Access-Control-Allow-Origin: *\r\n" \
|
|
191
|
+
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" \
|
|
192
|
+
"Access-Control-Allow-Headers: Content-Type, Last-Event-ID\r\n" \
|
|
193
|
+
"\r\n"
|
|
194
|
+
)
|
|
195
|
+
io.flush
|
|
196
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
197
|
+
raise
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Serialise one SSE event: an `id:` line (the replay cursor), an optional
|
|
201
|
+
# `event:` line for EventSource.addEventListener, one or more `data:` lines,
|
|
202
|
+
# and the terminating blank line.
|
|
203
|
+
def write_frame(io, seq:, data:)
|
|
204
|
+
lines = []
|
|
205
|
+
lines << "id: #{EventId.format(seq, @epoch)}"
|
|
206
|
+
event_type = data.is_a?(Hash) && (data[:type] || data["type"])
|
|
207
|
+
lines << "event: #{event_type}" if event_type && !event_type.to_s.empty?
|
|
208
|
+
JSON.generate(data).each_line { |line| lines << "data: #{line.chomp}" }
|
|
209
|
+
io.write(lines.join("\r\n") + "\r\n\r\n")
|
|
210
|
+
io.flush
|
|
211
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
212
|
+
raise
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def write_heartbeat(io)
|
|
216
|
+
io.write(": ping\r\n\r\n")
|
|
217
|
+
io.flush
|
|
218
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
219
|
+
raise
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Emit a control frame carrying the current snapshot so a client that
|
|
223
|
+
# cannot replay can re-derive state, then continue live.
|
|
224
|
+
def emit_reset(io)
|
|
225
|
+
write_snapshot_frame(io, :reset, @engine.synchronize_events { take_snapshot })
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# With the event log held: the snapshot, and the high-water mark moved
|
|
229
|
+
# to its seq (down too, for a cursor from before a worker restart), so
|
|
230
|
+
# events after it pass #call and events it covers are skipped.
|
|
231
|
+
def take_snapshot
|
|
232
|
+
snapshot = @turn_snapshot_provider.call
|
|
233
|
+
@high_water = snapshot[:event_seq].to_i
|
|
234
|
+
snapshot.merge(event_id: EventId.format(@high_water, @epoch))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def write_snapshot_frame(io, type, snapshot)
|
|
238
|
+
seq = snapshot[:event_seq]
|
|
239
|
+
state = @snapshot_provider.call.merge(event_seq: seq, event_id: snapshot[:event_id])
|
|
240
|
+
write_frame(io, seq: seq, data: { type: type, snapshot: snapshot, session_state_snapshot: state })
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def closed?
|
|
244
|
+
@mutex.synchronize { !@serving }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../kernel_loop"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class Bridge
|
|
7
|
+
# Folds the event log into the state of the turn in progress, so a UI
|
|
8
|
+
# that joins mid-turn can render all of it at once (the Bridge's snapshot
|
|
9
|
+
# frame) instead of replaying events the ring buffer may have dropped.
|
|
10
|
+
#
|
|
11
|
+
# A persistent observer: #call runs under the SessionObserver's lock,
|
|
12
|
+
# enqueue-cheap (appends to strings and arrays). The turn is cleared on
|
|
13
|
+
# its terminal event, the same event that brings its messages into the
|
|
14
|
+
# session, so a snapshot shows a turn in exactly one of the two.
|
|
15
|
+
class TurnAccumulator
|
|
16
|
+
TERMINAL_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(max_output_chars: KernelLoop::DEFAULT_MAX_TOOL_OUTPUT_CHARS)
|
|
19
|
+
@max_output_chars = max_output_chars
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@turn = nil
|
|
22
|
+
@queued = []
|
|
23
|
+
@merged_origins = []
|
|
24
|
+
@recap = nil
|
|
25
|
+
@continue_offer = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(event)
|
|
29
|
+
@mutex.synchronize { fold(event) }
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil # never break the running turn
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Hash, nil] a copy of the turn in progress: prompt, origin,
|
|
35
|
+
# continue, ordered parts (thinking / text / tool / input / reminder / hook_notice),
|
|
36
|
+
# pending_question and the last event_seq folded in
|
|
37
|
+
def current_turn
|
|
38
|
+
@mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The turn in progress as conversation messages, for a plugin's
|
|
42
|
+
# ctx.messages mid-turn (plan O1): its prompt (with its images), the
|
|
43
|
+
# model's text so far, and the lines merged into it, in order. Tool
|
|
44
|
+
# calls and thinking are left out.
|
|
45
|
+
# @return [Array<Hash>] [] with no turn running
|
|
46
|
+
def current_messages
|
|
47
|
+
@mutex.synchronize { @turn ? self.class.messages_of(@turn) : [] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param turn [Hash] #current_turn's shape
|
|
51
|
+
# @return [Array<Hash>] {role: "user"|"model", content:, images:}
|
|
52
|
+
def self.messages_of(turn)
|
|
53
|
+
messages = []
|
|
54
|
+
text = +""
|
|
55
|
+
flush = lambda do
|
|
56
|
+
messages << { role: "model", content: text.dup } unless text.strip.empty?
|
|
57
|
+
text.clear
|
|
58
|
+
end
|
|
59
|
+
unless turn[:prompt].nil?
|
|
60
|
+
prompt = { role: "user", content: turn[:prompt].to_s }
|
|
61
|
+
prompt[:images] = turn[:images] if turn[:images]
|
|
62
|
+
messages << prompt
|
|
63
|
+
end
|
|
64
|
+
Array(turn[:parts]).each do |part|
|
|
65
|
+
case part[:kind]
|
|
66
|
+
when "text" then text << part[:text].to_s
|
|
67
|
+
when "input"
|
|
68
|
+
flush.call
|
|
69
|
+
messages << { role: "user", content: part[:text].to_s }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
flush.call
|
|
73
|
+
messages
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String, nil] the last idle recap, until a turn makes it stale
|
|
77
|
+
def recap
|
|
78
|
+
@mutex.synchronize { @recap }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Hash, nil] a copy of the pending continue offer ({context:,
|
|
82
|
+
# no_interrupt:}), from :continue_offered until :continue_resolved
|
|
83
|
+
def continue_offer
|
|
84
|
+
@mutex.synchronize { @continue_offer && Marshal.load(Marshal.dump(@continue_offer)) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Array<Hash>] turns announced as queued that haven't started
|
|
88
|
+
# or been merged into a running turn yet
|
|
89
|
+
def queued
|
|
90
|
+
@mutex.synchronize { @queued.map(&:dup) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def fold(event)
|
|
96
|
+
type = event[:type]
|
|
97
|
+
case type
|
|
98
|
+
when :turn_enqueued
|
|
99
|
+
queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
|
|
100
|
+
queued[:images] = event[:images] if event[:images]
|
|
101
|
+
@queued << queued
|
|
102
|
+
when :recap_ready
|
|
103
|
+
# One collected just as a turn started describes the chat before it.
|
|
104
|
+
@recap = event[:recap] unless @turn
|
|
105
|
+
when :continue_offered
|
|
106
|
+
@continue_offer = { context: event[:context], no_interrupt: !!event[:no_interrupt] }
|
|
107
|
+
when :continue_resolved
|
|
108
|
+
@continue_offer = nil
|
|
109
|
+
when :turn_started
|
|
110
|
+
@recap = nil
|
|
111
|
+
dequeue([event[:origin]])
|
|
112
|
+
@turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
|
|
113
|
+
parts: [], pending_question: nil }
|
|
114
|
+
@turn[:images] = event[:images] if event[:images]
|
|
115
|
+
when :input_merged
|
|
116
|
+
@merged_origins = Array(event[:origins])
|
|
117
|
+
dequeue(@merged_origins)
|
|
118
|
+
end
|
|
119
|
+
return unless @turn
|
|
120
|
+
|
|
121
|
+
fold_turn_event(event)
|
|
122
|
+
if TERMINAL_EVENTS.include?(type)
|
|
123
|
+
@turn = nil
|
|
124
|
+
@merged_origins = []
|
|
125
|
+
else
|
|
126
|
+
@turn[:event_seq] = event[:event_seq] if event[:event_seq]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fold_turn_event(event)
|
|
131
|
+
parts = @turn[:parts]
|
|
132
|
+
case event[:type]
|
|
133
|
+
when :generation_chunk
|
|
134
|
+
if event.key?(:text)
|
|
135
|
+
# Profile-split stream: thinking and visible text separately.
|
|
136
|
+
append_text(event[:iteration], "thinking", event[:thinking])
|
|
137
|
+
append_text(event[:iteration], "text", event[:text])
|
|
138
|
+
else
|
|
139
|
+
append_text(event[:iteration], "text", event[:content])
|
|
140
|
+
end
|
|
141
|
+
when :tool_call_started
|
|
142
|
+
part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
|
|
143
|
+
tool: event[:tool], params: event[:params], status: "running" }
|
|
144
|
+
part[:label] = event[:label] if event[:label]
|
|
145
|
+
parts << part
|
|
146
|
+
when :tool_call_completed
|
|
147
|
+
tool = parts.reverse_each.find do |part|
|
|
148
|
+
part[:kind] == "tool" && part[:iteration] == event[:iteration] && part[:call_index] == event[:call_index]
|
|
149
|
+
end
|
|
150
|
+
return unless tool
|
|
151
|
+
|
|
152
|
+
output = event[:output].to_s
|
|
153
|
+
capped = output.length > @max_output_chars
|
|
154
|
+
tool[:status] = (event.dig(:activity, :status) || "ok").to_s
|
|
155
|
+
tool[:output] = capped ? output[0, @max_output_chars] : output.dup
|
|
156
|
+
tool[:output_truncated] = capped || !!event[:output_truncated]
|
|
157
|
+
tool[:images] = event[:images] if event[:images]
|
|
158
|
+
when :pending_input_merged
|
|
159
|
+
parts << { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
|
|
160
|
+
@merged_origins = []
|
|
161
|
+
when :reminder_injected
|
|
162
|
+
parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
|
|
163
|
+
when :hook_notice
|
|
164
|
+
parts << { kind: "hook_notice", hook: event[:hook].to_s, text: event[:text].to_s, level: event[:level].to_s }
|
|
165
|
+
when :question_requested
|
|
166
|
+
@turn[:pending_question] = event[:pending_question]&.dup
|
|
167
|
+
when :question_answered, :question_cancelled
|
|
168
|
+
@turn[:pending_question] = nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def append_text(iteration, kind, text)
|
|
173
|
+
return if text.nil? || text.empty?
|
|
174
|
+
|
|
175
|
+
last = @turn[:parts].last
|
|
176
|
+
if last && last[:kind] == kind && last[:iteration] == iteration
|
|
177
|
+
last[:text] << text
|
|
178
|
+
else
|
|
179
|
+
@turn[:parts] << { kind: kind, iteration: iteration, text: String.new(text) }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def dequeue(origins)
|
|
184
|
+
ids = origins.filter_map { |origin| origin.is_a?(Hash) ? origin[:enqueued_id] : nil }
|
|
185
|
+
@queued.reject! { |entry| ids.include?(entry[:enqueued_id]) } unless ids.empty?
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|