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,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class BridgeClient
|
|
8
|
+
# A Bridge event stream followed on a reader thread (BridgeClient#follow).
|
|
9
|
+
#
|
|
10
|
+
# Joins with `?snapshot=1`, so the first event is a `snapshot` frame, then
|
|
11
|
+
# yields each live event parsed from JSON (string keys). When the
|
|
12
|
+
# connection drops it reconnects with `Last-Event-ID`: the Bridge replays
|
|
13
|
+
# what was missed, or sends a `reset` frame carrying a fresh snapshot when
|
|
14
|
+
# it can't. Either snapshot kind means "re-render from here".
|
|
15
|
+
#
|
|
16
|
+
# When the Bridge stays unreachable through every reconnect delay, or does
|
|
17
|
+
# not serve the session (404), it yields one synthetic
|
|
18
|
+
# `{"type" => "stream_closed", "reason" => ...}` and stops. #close stops
|
|
19
|
+
# it without that event.
|
|
20
|
+
class EventStream
|
|
21
|
+
DEFAULT_RECONNECT_DELAYS = [0.1, 0.25, 0.5, 1.0, 2.0].freeze
|
|
22
|
+
HEADER_TIMEOUT = 5.0
|
|
23
|
+
|
|
24
|
+
# @return [String, nil] the id of the last frame yielded (the reconnect cursor)
|
|
25
|
+
attr_reader :last_event_id
|
|
26
|
+
|
|
27
|
+
# @param client [BridgeClient]
|
|
28
|
+
# @param snapshot [Boolean] join with a snapshot frame rather than a replay
|
|
29
|
+
# @param client_id [String, nil] names the stream, so the worker can
|
|
30
|
+
# tell this client's stream from others' (Bridge#open_streams_except)
|
|
31
|
+
# @param reconnect_delays [Array<Float>] sleeps between failed attempts;
|
|
32
|
+
# one more failure than there are delays gives up
|
|
33
|
+
# @yieldparam event [Hash] string-keyed event
|
|
34
|
+
def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, &on_event)
|
|
35
|
+
@client = client
|
|
36
|
+
params = []
|
|
37
|
+
params << ["snapshot", "1"] if snapshot
|
|
38
|
+
params << ["client_id", client_id] if client_id
|
|
39
|
+
@query = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
40
|
+
@reconnect_delays = reconnect_delays
|
|
41
|
+
@on_event = on_event
|
|
42
|
+
@last_event_id = nil
|
|
43
|
+
@closed = false
|
|
44
|
+
@sock = nil
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [self]
|
|
49
|
+
def start
|
|
50
|
+
@thread = Thread.new { run }
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stop following: closes the socket under the reader so it wakes at once.
|
|
55
|
+
# Safe to call from the event callback itself.
|
|
56
|
+
def close
|
|
57
|
+
sock = @mutex.synchronize do
|
|
58
|
+
@closed = true
|
|
59
|
+
@sock
|
|
60
|
+
end
|
|
61
|
+
begin
|
|
62
|
+
sock&.close
|
|
63
|
+
rescue IOError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
@thread&.join(1) unless Thread.current == @thread
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def alive? = !!@thread&.alive?
|
|
71
|
+
|
|
72
|
+
# @return [Thread, nil] the reader thread, nil when it did not finish in time
|
|
73
|
+
def join(timeout = nil) = @thread&.join(timeout)
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def run
|
|
78
|
+
failures = 0
|
|
79
|
+
until closed?
|
|
80
|
+
outcome = read_connection
|
|
81
|
+
break if closed?
|
|
82
|
+
return finish("unknown_session") if outcome == :unknown_session
|
|
83
|
+
|
|
84
|
+
if outcome == :events
|
|
85
|
+
failures = 0
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
failures += 1
|
|
90
|
+
return finish("unreachable") if failures > @reconnect_delays.size
|
|
91
|
+
|
|
92
|
+
sleep(@reconnect_delays[failures - 1])
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# One connection: :events when it yielded any, else why it failed.
|
|
97
|
+
def read_connection
|
|
98
|
+
sock, status = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
|
|
99
|
+
return :unreachable unless sock
|
|
100
|
+
|
|
101
|
+
unless attach(sock)
|
|
102
|
+
sock.close rescue nil
|
|
103
|
+
return :closed
|
|
104
|
+
end
|
|
105
|
+
return :unknown_session if status == 404
|
|
106
|
+
return :unreachable unless status == 200
|
|
107
|
+
|
|
108
|
+
yielded = false
|
|
109
|
+
parser = SSEParser.new
|
|
110
|
+
loop do
|
|
111
|
+
chunk = sock.readpartial(4096)
|
|
112
|
+
parser.feed(chunk) do |frame|
|
|
113
|
+
event = parse(frame[:data])
|
|
114
|
+
next unless event
|
|
115
|
+
|
|
116
|
+
@last_event_id = frame[:id] if frame[:id]
|
|
117
|
+
yielded = true
|
|
118
|
+
@on_event.call(event)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
rescue EOFError, IOError, SystemCallError
|
|
122
|
+
yielded ? :events : :unreachable
|
|
123
|
+
ensure
|
|
124
|
+
detach(sock)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Publish the socket for #close; false when already closed.
|
|
128
|
+
def attach(sock)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
return false if @closed
|
|
131
|
+
|
|
132
|
+
@sock = sock
|
|
133
|
+
end
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def detach(sock)
|
|
138
|
+
return unless sock
|
|
139
|
+
|
|
140
|
+
@mutex.synchronize { @sock = nil if @sock.equal?(sock) }
|
|
141
|
+
sock.close rescue nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse(data)
|
|
145
|
+
event = JSON.parse(data)
|
|
146
|
+
event.is_a?(Hash) ? event : nil
|
|
147
|
+
rescue JSON::ParserError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def finish(reason)
|
|
152
|
+
@on_event.call({ "type" => "stream_closed", "reason" => reason }) unless closed?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def closed? = @mutex.synchronize { @closed }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
class BridgeClient
|
|
5
|
+
# Incremental Server-Sent Events parser: feed it raw body chunks as they
|
|
6
|
+
# arrive and it yields one frame per blank-line-terminated event. Accepts
|
|
7
|
+
# CRLF or LF line ends, joins multi-line `data:` with newlines, and skips
|
|
8
|
+
# comments (the Bridge's `: ping` heartbeats) and frames without data.
|
|
9
|
+
class SSEParser
|
|
10
|
+
def initialize
|
|
11
|
+
@buffer = +""
|
|
12
|
+
reset_frame
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param chunk [String] raw bytes from the stream
|
|
16
|
+
# @yieldparam frame [Hash] {id: String|nil, event: String|nil, data: String}
|
|
17
|
+
def feed(chunk)
|
|
18
|
+
@buffer << chunk.to_s.b
|
|
19
|
+
while (newline = @buffer.index("\n"))
|
|
20
|
+
line = @buffer.slice!(0..newline).chomp("\n").chomp("\r").force_encoding(Encoding::UTF_8)
|
|
21
|
+
if line.empty?
|
|
22
|
+
yield({ id: @id, event: @event, data: @data.join("\n") }) if @data.any?
|
|
23
|
+
reset_frame
|
|
24
|
+
else
|
|
25
|
+
field(line)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def field(line)
|
|
33
|
+
return if line.start_with?(":")
|
|
34
|
+
|
|
35
|
+
name, value = line.split(":", 2)
|
|
36
|
+
value = value.to_s.delete_prefix(" ")
|
|
37
|
+
case name
|
|
38
|
+
when "id" then @id = value
|
|
39
|
+
when "event" then @event = value
|
|
40
|
+
when "data" then @data << value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_frame
|
|
45
|
+
@id = nil
|
|
46
|
+
@event = nil
|
|
47
|
+
@data = []
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
require_relative "bridge_client/sse_parser"
|
|
7
|
+
require_relative "bridge_client/event_stream"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
|
|
11
|
+
# each SessionManager worker runs for its Engine (see Bridge). Discovery goes
|
|
12
|
+
# through the worker's `bridge.json` sidecar in the session directory.
|
|
13
|
+
#
|
|
14
|
+
# Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
|
|
15
|
+
# exactly what the Bridge's minimal server expects.
|
|
16
|
+
class BridgeClient
|
|
17
|
+
HOST = "127.0.0.1"
|
|
18
|
+
SIDECAR_FILE = "bridge.json"
|
|
19
|
+
PROBE_TIMEOUT = 0.2
|
|
20
|
+
STREAM_CONNECT_ATTEMPTS = 3
|
|
21
|
+
# Seconds #stream waits for bytes before it asks `running` again.
|
|
22
|
+
STREAM_POLL = 0.5
|
|
23
|
+
# Seconds a one-shot request waits for the whole reply. Every route
|
|
24
|
+
# answers at once (a recap is only asked for); a worker that takes the
|
|
25
|
+
# request and never answers raises Errno::ETIMEDOUT, as a dead one
|
|
26
|
+
# raises Errno::ECONNREFUSED. The event stream has no such limit.
|
|
27
|
+
READ_TIMEOUT = 30
|
|
28
|
+
# Share of the read timeout a request's deadline allows (25 s of 30): the
|
|
29
|
+
# Bridge drops a turn, command, answer or dismissal it reads after the
|
|
30
|
+
# deadline, so one this client timed out on, and said did not go
|
|
31
|
+
# through, never runs when a frozen worker wakes. The rest covers the
|
|
32
|
+
# write and the reply.
|
|
33
|
+
DEADLINE_SHARE = 5 / 6r
|
|
34
|
+
|
|
35
|
+
# A Bridge reply: HTTP status code and raw body (nil when not read).
|
|
36
|
+
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
37
|
+
def ok? = status == 200
|
|
38
|
+
|
|
39
|
+
# @return [Hash, nil] the body parsed as JSON, or nil when it is not
|
|
40
|
+
def json
|
|
41
|
+
JSON.parse(body.to_s)
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# What a UI says when the Bridge answers a route with 404: the worker runs
|
|
48
|
+
# code from before the route. Restarting it picks up the installed chi.
|
|
49
|
+
# @param cant [String] what the old worker can't do, e.g. "run commands"
|
|
50
|
+
def self.stale_worker_message(session_id, cant:)
|
|
51
|
+
"this session's worker runs an older chi and can't #{cant}; " \
|
|
52
|
+
"restart it: chi sessions stop #{session_id} && chi --resume #{session_id} (its turns still work)"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Port of the live Bridge advertised in +session_dir+'s sidecar, or nil.
|
|
56
|
+
# A sidecar whose port refuses a quick connect is stale (its worker died)
|
|
57
|
+
# and is removed.
|
|
58
|
+
# @param session_dir [String]
|
|
59
|
+
# @return [Integer, nil]
|
|
60
|
+
def self.sidecar_port(session_dir, host: HOST)
|
|
61
|
+
sidecar = File.join(session_dir, SIDECAR_FILE)
|
|
62
|
+
return nil unless File.file?(sidecar)
|
|
63
|
+
|
|
64
|
+
data = JSON.parse(File.read(sidecar))
|
|
65
|
+
port = data["port"]
|
|
66
|
+
port = port.is_a?(Integer) ? port : port.to_i
|
|
67
|
+
return nil unless port.to_i > 0
|
|
68
|
+
|
|
69
|
+
# Validate liveness: stale sidecar after worker death causes ECONNREFUSED
|
|
70
|
+
# which surfaces as WEBrick ERROR. Probe quickly and clean up if dead.
|
|
71
|
+
begin
|
|
72
|
+
Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
|
|
73
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError, StandardError
|
|
74
|
+
begin
|
|
75
|
+
File.unlink(sidecar)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
return nil
|
|
80
|
+
end
|
|
81
|
+
port
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
|
|
87
|
+
def self.discover(session_id, session_dir:, host: HOST)
|
|
88
|
+
port = sidecar_port(session_dir, host: host)
|
|
89
|
+
port && new(session_id: session_id, port: port, host: host)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
93
|
+
# one stat, and a 0.1 s step cost a create up to 0.1 s (the web's
|
|
94
|
+
# create landed on 0.53/0.63/0.73 s).
|
|
95
|
+
SPAWN_POLL_INTERVAL = 0.02
|
|
96
|
+
|
|
97
|
+
# Wait for a freshly spawned worker to publish its Bridge.
|
|
98
|
+
# @param timeout [Float] seconds
|
|
99
|
+
# @return [BridgeClient, nil] nil when no live sidecar appeared in time
|
|
100
|
+
def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
|
|
101
|
+
port = poll(timeout, interval: SPAWN_POLL_INTERVAL) { sidecar_port(session_dir, host: host) }
|
|
102
|
+
port && new(session_id: session_id, port: port, host: host)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Call the block every +interval+ seconds until it returns a truthy value
|
|
106
|
+
# or +timeout+ seconds have passed (it is always called at least once).
|
|
107
|
+
# @return [Object, nil] the block's first truthy value
|
|
108
|
+
def self.poll(timeout, interval: 0.1)
|
|
109
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
|
|
110
|
+
loop do
|
|
111
|
+
value = yield
|
|
112
|
+
return value if value
|
|
113
|
+
return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
114
|
+
|
|
115
|
+
sleep(interval)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
attr_reader :session_id, :port, :host
|
|
120
|
+
|
|
121
|
+
# @param read_timeout [Numeric] see READ_TIMEOUT
|
|
122
|
+
def initialize(session_id:, port:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
123
|
+
@session_id = session_id
|
|
124
|
+
@port = port
|
|
125
|
+
@host = host
|
|
126
|
+
@read_timeout = read_timeout
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# POST /session/:id/answer. 200 = recorded, 409 = another client answered
|
|
130
|
+
# first (or the question is gone), 400 = invalid selection, 408
|
|
131
|
+
# deadline_passed = read too late and dropped (see DEADLINE_SHARE).
|
|
132
|
+
# @return [Response]
|
|
133
|
+
def answer(id:, selected:, freeform: nil)
|
|
134
|
+
post("answer", { id: id, selected: selected, freeform: freeform, deadline: deadline }, read_body: true)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# POST /session/:id/question/dismiss: leave the question unanswered.
|
|
138
|
+
# 200 = dismissed, 409 = no longer pending (answered, cancelled, or
|
|
139
|
+
# another question), 404 = a worker older than the route, 408 = too late.
|
|
140
|
+
# @return [Response]
|
|
141
|
+
def dismiss_question(id:)
|
|
142
|
+
post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# POST /session/:id/turn. 202 = queued (body carries the enqueued_id the
|
|
146
|
+
# Bridge also announced in :turn_enqueued), 408 deadline_passed = the
|
|
147
|
+
# Bridge read it too late and dropped it (see DEADLINE_SHARE).
|
|
148
|
+
# @param client_id [String, nil] identifies the sending UI in the events
|
|
149
|
+
# @return [Response]
|
|
150
|
+
# @param no_interrupt [Boolean] run the turn with the raised iteration limit
|
|
151
|
+
# @param images [Array<Hash>] refs ({file:, name:}) to images already in
|
|
152
|
+
# the session's images/ (409 images_unsupported from an older worker)
|
|
153
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil)
|
|
154
|
+
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
155
|
+
body[:no_interrupt] = true if no_interrupt
|
|
156
|
+
body[:images] = images if images && !images.empty?
|
|
157
|
+
body[:deadline] = deadline
|
|
158
|
+
post("turn", body, read_body: true)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# POST /session/:id/command: a session command (/model, /models,
|
|
162
|
+
# !rollback, !cmd, /continue) for the worker to run. 202 = queued (body
|
|
163
|
+
# carries the command_id its :command_ran will name), 400 = not a
|
|
164
|
+
# command, 404 = a worker older than the route, 408 = too late.
|
|
165
|
+
# @return [Response]
|
|
166
|
+
def post_command(line:, client_id: nil)
|
|
167
|
+
post("command", { line: line, client_id: client_id, deadline: deadline }, read_body: true)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# POST /session/:id/exit: ask the worker to exit now. 200 = it will
|
|
171
|
+
# (status "exiting"), 409 = something keeps it up (reason), 404 with
|
|
172
|
+
# error "not_found" = a worker older than the route.
|
|
173
|
+
# @param client_id [String] the asking UI, whose own streams don't hold
|
|
174
|
+
# @return [Response]
|
|
175
|
+
def request_exit(client_id:, delete: false)
|
|
176
|
+
body = { client_id: client_id }
|
|
177
|
+
body[:delete] = true if delete
|
|
178
|
+
post("exit", body, read_body: true)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# POST /session/:id/recap: the saved recap, and a new one asked for.
|
|
182
|
+
# @return [Response] 200 {enabled, saved, request, min_user_turns}
|
|
183
|
+
def request_recap
|
|
184
|
+
post("recap", {}, read_body: true)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# POST /session/:id/cancel. 202 = requested, 409 = no active turn.
|
|
188
|
+
# @return [Response] (status only)
|
|
189
|
+
def cancel(reason:)
|
|
190
|
+
post("cancel", { reason: reason }, read_body: false)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# GET /session/:id/<path> as JSON.
|
|
194
|
+
# @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
|
|
195
|
+
def get_json(path)
|
|
196
|
+
sock = TCPSocket.new(@host, @port)
|
|
197
|
+
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
|
|
198
|
+
response = read_reply(sock, path)
|
|
199
|
+
sock.close rescue nil
|
|
200
|
+
return nil unless response
|
|
201
|
+
|
|
202
|
+
status_line = response.lines.first.to_s
|
|
203
|
+
return nil unless status_line.include?("200")
|
|
204
|
+
|
|
205
|
+
body = response.split("\r\n\r\n", 2)[1] || ""
|
|
206
|
+
JSON.parse(body)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
|
|
212
|
+
# @return [Integer, nil]
|
|
213
|
+
def event_seq
|
|
214
|
+
state = get_json("state")
|
|
215
|
+
seq = state && state.dig("session_state_snapshot", "event_seq")
|
|
216
|
+
seq.nil? ? nil : seq.to_i
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# GET /session/:id/stream: yield the raw SSE body bytes as they arrive
|
|
222
|
+
# until the Bridge closes the stream.
|
|
223
|
+
# @param query [String] "" or "?from_seq=N"
|
|
224
|
+
# @param last_event_id [String, nil] reconnect cursor; the Bridge prefers it
|
|
225
|
+
# over ?from_seq
|
|
226
|
+
# @param running [#call, nil] asked before every read, and every
|
|
227
|
+
# STREAM_POLL seconds of silence: the stream ends once it returns
|
|
228
|
+
# false. A quiet stream is never cut otherwise.
|
|
229
|
+
def stream(query: "", last_event_id: nil, running: nil)
|
|
230
|
+
sock = nil
|
|
231
|
+
# Absorb the probe→connect race around a resumed worker's bridge:
|
|
232
|
+
# the sidecar probe can succeed a moment before the worker dies (or
|
|
233
|
+
# the bridge socket briefly refuses). A few quick bounded retries
|
|
234
|
+
# avoid the silent empty-200 that EventSource would otherwise keep
|
|
235
|
+
# re-opening.
|
|
236
|
+
STREAM_CONNECT_ATTEMPTS.times do |attempt|
|
|
237
|
+
sock, = connect_stream(query: query, last_event_id: last_event_id)
|
|
238
|
+
break if sock
|
|
239
|
+
|
|
240
|
+
sleep(0.15 * (attempt + 1))
|
|
241
|
+
end
|
|
242
|
+
return unless sock
|
|
243
|
+
|
|
244
|
+
begin
|
|
245
|
+
loop do
|
|
246
|
+
if running
|
|
247
|
+
break unless running.call
|
|
248
|
+
next unless sock.wait_readable(STREAM_POLL)
|
|
249
|
+
end
|
|
250
|
+
chunk = sock.readpartial(4096)
|
|
251
|
+
yield chunk
|
|
252
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::ECONNREFUSED
|
|
253
|
+
break
|
|
254
|
+
end
|
|
255
|
+
ensure
|
|
256
|
+
sock&.close rescue nil
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Follow the session's events on a reader thread (see EventStream).
|
|
261
|
+
# @param snapshot [Boolean] join with a snapshot frame
|
|
262
|
+
# @param client_id [String, nil] names the stream (see EventStream)
|
|
263
|
+
# @yieldparam event [Hash] string-keyed event
|
|
264
|
+
# @return [EventStream] started
|
|
265
|
+
def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, &on_event)
|
|
266
|
+
EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
|
|
267
|
+
&on_event).start
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Open GET /session/:id/stream and read past the response headers.
|
|
271
|
+
# @param timeout [Float, nil] give up when the headers don't start in time
|
|
272
|
+
# @return [Array(TCPSocket, Integer), nil] the socket positioned at the
|
|
273
|
+
# body and the HTTP status, or nil when the Bridge can't be reached
|
|
274
|
+
def connect_stream(query: "", last_event_id: nil, timeout: nil)
|
|
275
|
+
sock = TCPSocket.new(@host, @port)
|
|
276
|
+
lei = last_event_id.to_s.strip
|
|
277
|
+
last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
|
|
278
|
+
sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
|
|
279
|
+
raise Errno::ETIMEDOUT if timeout && !sock.wait_readable(timeout)
|
|
280
|
+
|
|
281
|
+
status = sock.gets.to_s[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i
|
|
282
|
+
while (line = sock.gets)
|
|
283
|
+
break if line.strip.empty?
|
|
284
|
+
end
|
|
285
|
+
[sock, status]
|
|
286
|
+
rescue SystemCallError, SocketError, IOError
|
|
287
|
+
sock&.close rescue nil
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private
|
|
292
|
+
|
|
293
|
+
# When this client stops waiting for a reply, as epoch seconds (see
|
|
294
|
+
# DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
|
|
295
|
+
# the same one.
|
|
296
|
+
def deadline = (Time.now.to_f + (@read_timeout * DEADLINE_SHARE)).round(3)
|
|
297
|
+
|
|
298
|
+
def post(path, payload, read_body:)
|
|
299
|
+
sock = TCPSocket.new(@host, @port)
|
|
300
|
+
json_body = JSON.generate(payload)
|
|
301
|
+
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}")
|
|
302
|
+
reply = read_reply(sock, path, whole: read_body)
|
|
303
|
+
body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
|
|
304
|
+
Response.new(status: reply[/\AHTTP\/1\.[01] (\d{3})/, 1].to_i, body: body)
|
|
305
|
+
ensure
|
|
306
|
+
sock&.close rescue nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The reply up to the Bridge's close (or its first line only), within
|
|
310
|
+
# @read_timeout in all.
|
|
311
|
+
# @raise [Errno::ETIMEDOUT]
|
|
312
|
+
def read_reply(sock, path, whole: true)
|
|
313
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
|
|
314
|
+
reply = +""
|
|
315
|
+
loop do
|
|
316
|
+
break if !whole && reply.include?("\n")
|
|
317
|
+
|
|
318
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
319
|
+
unless left.positive? && sock.wait_readable(left)
|
|
320
|
+
raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{@read_timeout}s"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
reply << sock.readpartial(16_384)
|
|
324
|
+
rescue EOFError
|
|
325
|
+
break
|
|
326
|
+
end
|
|
327
|
+
reply.force_encoding(Encoding::UTF_8)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
autoload :MutedMemories, File.expand_path("muted_memories", __dir__)
|
|
7
|
+
autoload :Log, File.expand_path("log", __dir__)
|
|
8
|
+
module MemoryBundle
|
|
9
|
+
autoload :Provenance, File.expand_path("memory_bundle/provenance", __dir__)
|
|
10
|
+
autoload :Manifest, File.expand_path("memory_bundle/manifest", __dir__)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The outside commands a bundle declares in its manifest's `needs:`
|
|
14
|
+
# (docs/memory.md). Advisory only: a PATH lookup in Ruby, no subprocess,
|
|
15
|
+
# nothing installed. Install/upgrade warn, `chi bundle status` lists, and
|
|
16
|
+
# the prompt's index marks a bundle memory whose need is missing.
|
|
17
|
+
module BundleNeeds
|
|
18
|
+
# Whether some PATH folder holds an executable file (not a directory)
|
|
19
|
+
# named +command+. An empty or missing PATH finds nothing.
|
|
20
|
+
def self.found?(command, path: ENV["PATH"])
|
|
21
|
+
return false if path.nil? || path.empty?
|
|
22
|
+
|
|
23
|
+
path.split(File::PATH_SEPARATOR).any? do |dir|
|
|
24
|
+
next false if dir.empty?
|
|
25
|
+
|
|
26
|
+
file = File.join(dir, command.to_s)
|
|
27
|
+
File.file?(file) && File.executable?(file)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The needs (hashes with :command) whose command isn't found.
|
|
32
|
+
def self.missing(needs, path: ENV["PATH"])
|
|
33
|
+
Array(needs).reject { |need| found?(need[:command], path: path) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# "[needs gh, jq: not found on PATH]", or nil when nothing is missing.
|
|
37
|
+
# No em dash: IndexUpdater.extract_description splits on " — ".
|
|
38
|
+
def self.marker(missing)
|
|
39
|
+
commands = Array(missing).map { |need| need[:command] }
|
|
40
|
+
return nil if commands.empty?
|
|
41
|
+
|
|
42
|
+
"[needs #{commands.join(", ")}: not found on PATH]"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The prompt's index text for +scope+ with a marker on each line naming
|
|
46
|
+
# a memory of an installed bundle (of that scope) whose needs aren't all
|
|
47
|
+
# on +path+. Every other byte stays; with nothing missing the text comes
|
|
48
|
+
# back as is. Never raises: on any error the text comes back unchanged.
|
|
49
|
+
def self.annotate_index(text, scope, path: ENV["PATH"], bundles_dir: nil)
|
|
50
|
+
return text if text.nil? || text.empty?
|
|
51
|
+
|
|
52
|
+
markers = entry_markers(scope, path: path, bundles_dir: bundles_dir || MemoryBundle::Provenance.bundles_dir)
|
|
53
|
+
return text if markers.empty?
|
|
54
|
+
|
|
55
|
+
text.each_line.map do |line|
|
|
56
|
+
marker = markers[MutedMemories.index_line_name(line)]
|
|
57
|
+
next line unless marker
|
|
58
|
+
|
|
59
|
+
body = line.chomp
|
|
60
|
+
"#{body} #{marker}#{line[body.length..]}"
|
|
61
|
+
end.join
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
Log.debug(:memory, "bundle_needs_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
64
|
+
text
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# entry name → marker, for the installed bundles of +scope+ with a
|
|
68
|
+
# missing need. A manifest.json or needs: that doesn't parse is skipped.
|
|
69
|
+
def self.entry_markers(scope, path:, bundles_dir:)
|
|
70
|
+
return {} unless Dir.exist?(bundles_dir)
|
|
71
|
+
|
|
72
|
+
Dir[File.join(bundles_dir, "*", "manifest.json")].sort.each_with_object({}) do |mjson, acc|
|
|
73
|
+
data = begin
|
|
74
|
+
JSON.parse(File.read(mjson), symbolize_names: true)
|
|
75
|
+
rescue JSON::ParserError, SystemCallError
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
next unless data.is_a?(Hash) && data[:scope].to_s == scope.to_s
|
|
79
|
+
next unless data[:needs].is_a?(Array) && !data[:needs].empty?
|
|
80
|
+
|
|
81
|
+
needs = begin
|
|
82
|
+
MemoryBundle::Manifest.parse_needs(data[:needs])
|
|
83
|
+
rescue MemoryBundle::Manifest::ValidationError
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
marker = marker(missing(needs, path: path))
|
|
87
|
+
next unless marker
|
|
88
|
+
|
|
89
|
+
(data[:files].is_a?(Hash) ? data[:files].keys : []).each do |key|
|
|
90
|
+
name = MutedMemories.normalize(key)
|
|
91
|
+
acc[name] ||= marker if name
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
private_class_method :entry_markers
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: btw
|
|
3
|
+
version: 0.1.1
|
|
4
|
+
scope: system
|
|
5
|
+
description: "/btw <question>: a side answer about the conversation, as a card in every UI, even mid-turn; Keep as session forks it into a child session"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:d12a970e7e5ada617fc7d55ee1de80b576835092d268af620f3f7241188e3297
|
|
10
|
+
requires_chi: ">= 0.1.30"
|