samagotchi 0.122.0 → 0.123.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 +39 -1
- data/docs/architecture.md +17 -6
- data/docs/cli.md +32 -2
- data/docs/configuration.md +31 -6
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +7 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -8
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +34 -10
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +31 -2
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +5 -1
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +24 -3
- data/lib/samagotchi/web/update_check.rb +179 -0
- data/lib/samagotchi/worker.rb +44 -6
- metadata +5 -1
|
@@ -73,6 +73,16 @@ right-hand column happens.
|
|
|
73
73
|
beside `apply_staged_tools!`, then `reset!`): the chat loop builds `tools:` per request, so a mid-turn change
|
|
74
74
|
would differ from the turn's prompt. Under `full` nothing is filtered and no line is added: the bytes are what
|
|
75
75
|
they were before toolkits.
|
|
76
|
+
- A toolkit suggestion (`toolkit.suggest.mode: on`) is set before `apply_toolkit!` of the turn it was asked on
|
|
77
|
+
(`Engine#settle_toolkit_suggestion` → `ToolkitBinding#suggest`), and no request goes out before that, so on a
|
|
78
|
+
session's first turn it costs nothing. Its **deferred switch** (a later prompt after `undecided`, within
|
|
79
|
+
`toolkit.suggest.defer_turns` / `defer_tokens`) is engine-side, so `/toolkit`'s CacheConfirm doesn't apply: it
|
|
80
|
+
changes the declared tools at the front of the request, and the server re-reads **system prompt + tools +
|
|
81
|
+
conversation** on that turn. The defer bound keeps that conversation small; its notice says "from this turn".
|
|
82
|
+
When System 1 runs on the model's own single-slot local server (`Engine#toolkit_judge_shares_local_host?`), any
|
|
83
|
+
ask takes the slot and evicts its cached prefix: a first-turn ask costs that cache even when nothing switches
|
|
84
|
+
(there is little cached on a session's first turn), so on mode asks only there, skips the deferred path, and
|
|
85
|
+
sends no worker warm-up.
|
|
76
86
|
- A kit's prompt text (`toolkit.kits.<name>.prompt:`, `chi.toolkit … prompt:`) sits after the appended files and
|
|
77
87
|
before the memory indexes (`SystemPrompt#kit_prompt_section`): one prefix per kit, never per-session text, so
|
|
78
88
|
sessions on the kit share it; a memory write (the indexes after it) doesn't move it. It is part of the
|
|
@@ -119,13 +129,15 @@ right-hand column happens.
|
|
|
119
129
|
dropped or brought back) is a tail system message after the history, one per turn with ops, escaped as context
|
|
120
130
|
notes are (`Prompt::ESCAPED_KINDS`). The turn-end warm-up never warms a drop sent after the turn: it misses once.
|
|
121
131
|
- **Participants** ([participants.md](participants.md#attribution)). A participant's message carries a wire-only
|
|
122
|
-
header line (`Steer.review_header`), a pure function of the saved message (its `author_name`
|
|
123
|
-
replay sends the same bytes; the owner's
|
|
124
|
-
and on a Messages host). The
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
header line (`Steer.review_header`), a pure function of the saved message (its `author_name` and its
|
|
133
|
+
`participant_mode` stamp included: a review or a pair header), so a replay sends the same bytes; the owner's
|
|
134
|
+
messages are sent as before (`spec/review_contribution_spec.rb`, per loop and on a Messages host). The
|
|
135
|
+
participants line opens the system prompt's volatile tail, after breakpoint #1: a new session's cache is
|
|
136
|
+
untouched. Its words follow the session's `ParticipantMode` (a review or a working session's line), which never
|
|
137
|
+
changes for a session. The Engine checks the sidecar at each turn's start and rebuilds (`reset!`) only when the
|
|
138
|
+
first participant joins and when the last leaves or expires: one history re-read each, then stable. Under D3(a)
|
|
139
|
+
the declared tools stay the session's, so an author switch costs nothing: the review and pair boundaries are
|
|
140
|
+
gates on the calls, not a toolkit switch. The forged-header mark (`(quoted) `) is a pure function of the text too.
|
|
129
141
|
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt (the recap's also
|
|
130
142
|
asks for its auto title line, `RecapPrompt::TITLE`: static text, still that one request). On a current
|
|
131
143
|
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
data/docs/internals/web-push.md
CHANGED
|
@@ -101,10 +101,12 @@ The service worker is the only reader of the payload (JSON, at most `Notifier::O
|
|
|
101
101
|
|
|
102
102
|
- `{"v":1,"kind":"show","tag":KEY,"title":…,"body":…,"url":URL,"badge":N,"open":[KEY…]}`: a waiting
|
|
103
103
|
reason's (approval, question, continue, card) TTL 12 h (`SHOW_TTL`), `Urgency: high`; a failed or done turn's
|
|
104
|
-
TTL 10 min (`EVENT_TTL`), `Urgency: normal
|
|
104
|
+
TTL 10 min (`EVENT_TTL`), `Urgency: normal`; an available chi update uses a 24 h TTL (`UPDATE_TTL`) and
|
|
105
|
+
`Urgency: normal`. `open` only while the whole list fits the limit; the test push
|
|
105
106
|
(`chi:test`) has neither `open` nor `badge`. URL (`Notifier.session_url`) is the session in its own project's
|
|
106
107
|
view, `/?dir=<project root>#/s/<id>` (the folder URL-encoded, its slashes kept), or `/?scope=all#/s/<id>` for a
|
|
107
|
-
session in no repo: never the bare `/`, which is chi web's start project
|
|
108
|
+
session in no repo: never the bare `/`, which is chi web's start project (except the update show, which names no
|
|
109
|
+
session: `/`).
|
|
108
110
|
|
|
109
111
|
`badge` and `open` are the waiting keys still open in the Notifier's summaries (`Attention.open_keys`: a pending
|
|
110
112
|
question of any reason, a hook's and a continue's too, not relayed, and a card with actions; never a failed or done
|
|
@@ -160,7 +162,9 @@ the top and in `pending_question` / `pending_card`, strings in `last_turn`), `no
|
|
|
160
162
|
from chi web's request threads (`SessionHub#touch`, from the stop/archive/delete routes), so it only enqueues: a push
|
|
161
163
|
is a 0.5 s https call. `emit` already rescues a raising sink. The sink keeps the last summary per session and turns
|
|
162
164
|
each `session` / `session_gone` event into a `Show` (with its text) or a `Close` (it was answered anywhere) on a
|
|
163
|
-
Queue.
|
|
165
|
+
Queue. A `chi` event marked `update_notice` becomes a `chi:update:<version>` show for the bare page; the checker
|
|
166
|
+
sets that marker only for a newly selected version not already in its persistent `notified` list. It uses the same
|
|
167
|
+
Presence skip and hourly CAP as session shows, and the 24-hour TTL lets an offline phone receive it later.
|
|
164
168
|
|
|
165
169
|
**Seeding**: `Notifier#start(hub)` subscribes with `snapshot: true` and takes the summaries as the seed, so what is
|
|
166
170
|
open already never pushes. The hub's projection is empty before its first scan, so `Server.start` runs `hub.scan`
|
data/docs/sessions.md
CHANGED
|
@@ -264,7 +264,9 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
|
|
|
264
264
|
## Review sessions
|
|
265
265
|
|
|
266
266
|
A **review session** is one made to be shared with participants (the shared-sessions plan's D2: a review is its own
|
|
267
|
-
session, so a participant's review turn can't quote a working session's tool outputs
|
|
267
|
+
session, so a participant's review turn can't quote a working session's tool outputs, and only reads). Sharing a
|
|
268
|
+
working session itself is pairing: a participant's turns there act with its tools under the owner's guardrails
|
|
269
|
+
([CLI: Participants](cli.md#participants), [guardrails](guardrails.md#a-participants-pair-boundary)). `chi review FILE…` starts one
|
|
268
270
|
in a project, `chi review --from ID` as a review fork of a working session (see [CLI: Review
|
|
269
271
|
sessions](cli.md#review-sessions)); both start idle and print the `chi share` line to invite someone.
|
|
270
272
|
|
|
@@ -292,7 +294,8 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
|
|
|
292
294
|
space-separated, relative to the page's folder; a path with a space in double quotes, `"docs/my plan.md"`): its
|
|
293
295
|
chip then reads `review: plan.md +1`, and Start makes a review session (the message typed, if any, is its first
|
|
294
296
|
turn; with none it starts idle). A refused file shows the server's one message in the chip's form, opened again. On a session that isn't a review session the participants panel (the session bar's
|
|
295
|
-
participants chip) says so before its Invite, with the share warning (
|
|
297
|
+
participants chip) says so before its Invite, with the share warning (that a participant's turns act there, writes
|
|
298
|
+
included and asks to the owner, or only read while an older worker holds it; how many tool outputs its history holds)
|
|
296
299
|
and **Start a review of it**: a review fork of it, opened, with a toast (`Review 4b7e9a01 started from 3f9a1c2e's
|
|
297
300
|
recap; 3f9a1c2e keeps running.`) whose **Invite** opens the new session's panel. Every summary carries `review`
|
|
298
301
|
(null for other sessions).
|
|
@@ -311,7 +314,8 @@ declares every tool (full). The file stores only the name: a kit's tools and pro
|
|
|
311
314
|
|
|
312
315
|
`toolkit_origin` records why the session is on that kit: `default` for the configured kit, `chosen` for a
|
|
313
316
|
user-selected kit (including `/toolkit` and `ctx.use_toolkit`), `inherited` for a continue or fork, or `suggested`
|
|
314
|
-
|
|
317
|
+
when `toolkit.suggest.mode: on` switched it ([Toolkits](configuration.md#toolkits)). A session file from before the
|
|
318
|
+
field reads as `chosen`. Only a `default` session is ever suggested a kit.
|
|
315
319
|
|
|
316
320
|
A `find_tools` result on a chat host saves the tools its hit loads as `loaded: ["list_sessions", "mcp_fs_read"]` on
|
|
317
321
|
its `tool_response` entry (wire names: a built-in's, an MCP tool's `mcp_<server>_<tool>`), whatever the host: a
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -629,8 +629,11 @@ module Samagotchi
|
|
|
629
629
|
# review_turns: a participant's turn runs as its own, inside the review
|
|
630
630
|
# boundary (Guardrails::ReviewBoundary); a worker without it has none,
|
|
631
631
|
# so chi web sends it no participant's turn (Delivery::REVIEW_FEATURE).
|
|
632
|
+
# pair_turns: a participant's turn in a working session runs as a pair
|
|
633
|
+
# turn (Guardrails::PairBoundary); a worker without it runs it as a
|
|
634
|
+
# review turn, and chi web says so (Delivery::PAIR_FEATURE).
|
|
632
635
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
633
|
-
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE].freeze
|
|
636
|
+
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
|
|
634
637
|
|
|
635
638
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
636
639
|
# the session id and the request body and returns [headers, status, body].
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -249,7 +249,8 @@ module Samagotchi
|
|
|
249
249
|
# delegate child's. toolkit.kits (the user's own) is read by Toolkit.kits.
|
|
250
250
|
Entry.new(key: "toolkit.default", yaml_path: %w[toolkit default], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
|
|
251
251
|
Entry.new(key: "toolkit.child", yaml_path: %w[toolkit child], type: :string, default: nil, expose: %i[env config], lifecycle: :session_creation),
|
|
252
|
-
# Toolkit suggestion (ToolkitSuggest): off
|
|
252
|
+
# Toolkit suggestion (ToolkitSuggest): off; audit (logs, never blocks or changes a session); on (the first eligible
|
|
253
|
+
# turn waits up to guardrails.system_one.timeout_ms and may start on the suggested kit).
|
|
253
254
|
Entry.new(key: "toolkit.suggest.mode", yaml_path: %w[toolkit suggest mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :worker_start),
|
|
254
255
|
Entry.new(key: "toolkit.suggest.threshold", yaml_path: %w[toolkit suggest threshold], type: :float, default: 0.5, expose: %i[config], lifecycle: :worker_start),
|
|
255
256
|
Entry.new(key: "toolkit.suggest.defer_turns", yaml_path: %w[toolkit suggest defer_turns], type: :integer, default: 3, expose: %i[config], lifecycle: :worker_start),
|
|
@@ -289,6 +290,11 @@ module Samagotchi
|
|
|
289
290
|
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
|
|
290
291
|
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
|
|
291
292
|
Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config], lifecycle: :next_update),
|
|
293
|
+
Entry.new(key: "update.notify", yaml_path: %w[update notify], type: :enum, default: "on", expose: %i[env config], enum_values: %w[on off], lifecycle: :web_start),
|
|
294
|
+
Entry.new(key: "update.check_hours", yaml_path: %w[update check_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
|
|
295
|
+
check: ->(value) { "at least 1" unless value >= 1 }),
|
|
296
|
+
Entry.new(key: "update.min_age_hours", yaml_path: %w[update min_age_hours], type: :integer, default: 24, expose: %i[env config], lifecycle: :web_start,
|
|
297
|
+
check: ->(value) { "at least 0" unless value >= 0 }),
|
|
292
298
|
# The desktop helper's kitty targets (Desktop::MacOS.kitty_settings, one of its terminal_settings): listen_on copied from kitty.conf (unset = none),
|
|
293
299
|
# the kitty binary, and the foreground programs listed, "|"-separated (a YAML list works too; "*" = every window).
|
|
294
300
|
Entry.new(key: "kitty.listen_on", yaml_path: %w[kitty listen_on], type: :string, default: nil, expose: %i[env config], lifecycle: :desktop_update),
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -58,6 +58,13 @@ module Samagotchi
|
|
|
58
58
|
"the owner must restart it (chi sessions restart ID, or the web's update badge)"
|
|
59
59
|
}.freeze
|
|
60
60
|
|
|
61
|
+
# A worker that runs a participant's turn in a working session as a pair
|
|
62
|
+
# turn (ParticipantMode :pair, Guardrails::PairBoundary). One without it
|
|
63
|
+
# still runs it as a review turn (ReviewBoundary), so chi web and chi
|
|
64
|
+
# share treat the session as :review (ParticipantMode.live) until it
|
|
65
|
+
# restarts.
|
|
66
|
+
PAIR_FEATURE = "pair_turns"
|
|
67
|
+
|
|
61
68
|
# Whether it is the one every sender defaulting to a step boundary uses.
|
|
62
69
|
def self.next_step?(value) = parse(value) == NEXT_STEP
|
|
63
70
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -159,6 +159,7 @@ module Samagotchi
|
|
|
159
159
|
@chat_backend = nil
|
|
160
160
|
@chat_backend_mutex = Mutex.new
|
|
161
161
|
@toolkit_audit_thread = nil
|
|
162
|
+
@toolkit_warmup_thread = nil
|
|
162
163
|
@host_registry = host_registry || HostRegistry.new
|
|
163
164
|
# A model given here (a worker's session model) is the one it runs,
|
|
164
165
|
# so its host is checked; the config default is only checked once it
|
|
@@ -240,7 +241,7 @@ module Samagotchi
|
|
|
240
241
|
# start: their names for the messages they wrote.
|
|
241
242
|
@participants = SessionParticipants.new(state_dir: -> { session_state_dir })
|
|
242
243
|
# Whether the system prompt carries the participants line.
|
|
243
|
-
@participants_line =
|
|
244
|
+
@participants_line = nil
|
|
244
245
|
# Who is in the review now, and who came or went (#announce_participants).
|
|
245
246
|
@participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
246
247
|
@session = nil
|
|
@@ -381,6 +382,9 @@ module Samagotchi
|
|
|
381
382
|
@kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
|
|
382
383
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
383
384
|
@kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
|
|
385
|
+
@kernel.participant_mode = -> { @guardrail_wiring.turn_mode }
|
|
386
|
+
# A pair turn stops once its participant passed their cap.
|
|
387
|
+
@kernel.generation_guard = -> { guard_participant_spend }
|
|
384
388
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
385
389
|
@prompt_warmup = PromptWarmup.new
|
|
386
390
|
@kernel.warmup = @prompt_warmup
|
|
@@ -410,7 +414,10 @@ module Samagotchi
|
|
|
410
414
|
# from config on every ask, and the running turn's cancel controller
|
|
411
415
|
# closes a judgment that is no longer wanted.
|
|
412
416
|
system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
|
|
413
|
-
cancel_controller: -> { active_cancel_controller }
|
|
417
|
+
cancel_controller: -> { active_cancel_controller },
|
|
418
|
+
# A participant's turn: their name on the owner's card, their
|
|
419
|
+
# grant's mode for the turn's (GuardrailWiring#turn_mode).
|
|
420
|
+
participant: ->(author) { @participants.entry_for(author, session: @session) }
|
|
414
421
|
)
|
|
415
422
|
end
|
|
416
423
|
private :build_guardrail_wiring
|
|
@@ -1115,6 +1122,8 @@ module Samagotchi
|
|
|
1115
1122
|
thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
1116
1123
|
end
|
|
1117
1124
|
@toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
|
|
1125
|
+
# The warm-up's answer is dropped anyway.
|
|
1126
|
+
stop_toolkit_warmup
|
|
1118
1127
|
left = threads.count(&:alive?)
|
|
1119
1128
|
Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
|
|
1120
1129
|
@plugin_tasks.kill_leftovers
|
|
@@ -1395,6 +1404,34 @@ module Samagotchi
|
|
|
1395
1404
|
|
|
1396
1405
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1397
1406
|
|
|
1407
|
+
# Whether a participant's pair turn must stop, read before each of its
|
|
1408
|
+
# generations (#guard_participant_spend): author ("p:<id>") → why (their
|
|
1409
|
+
# link ended, or they passed their cap), nil while it may go on. The
|
|
1410
|
+
# Worker sets it (Worker#participant_turn_stop); without one (the REPL)
|
|
1411
|
+
# nothing is checked mid-turn.
|
|
1412
|
+
attr_writer :participant_stop
|
|
1413
|
+
|
|
1414
|
+
# What a participant turn's stop is attributed to (HookRoutes#stop_turn).
|
|
1415
|
+
PARTICIPANTS_HOOK = "participants"
|
|
1416
|
+
|
|
1417
|
+
# Before each generation (KernelLoop#generation_guard): a pair turn whose
|
|
1418
|
+
# participant's link ended, or whose spend (Engine#metrics: their
|
|
1419
|
+
# finished turns, this one's finished generations, their side requests)
|
|
1420
|
+
# passed their cap, stops on the plugins' stop path (cost-cap's,
|
|
1421
|
+
# work-cap's), with its notice. A generation's own cost is known only
|
|
1422
|
+
# once it ends, so a turn can pass the cap by its last generation. The
|
|
1423
|
+
# turn-start check is the Worker's (Worker#participant_refusal); a
|
|
1424
|
+
# review turn only reads, and keeps to it.
|
|
1425
|
+
def guard_participant_spend
|
|
1426
|
+
author = turn_author
|
|
1427
|
+
return if author.nil? || @participant_stop.nil?
|
|
1428
|
+
return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
|
|
1429
|
+
|
|
1430
|
+
reason = @participant_stop.call(author) or return
|
|
1431
|
+
hook_stop_turn(reason, PARTICIPANTS_HOOK)
|
|
1432
|
+
end
|
|
1433
|
+
private :guard_participant_spend
|
|
1434
|
+
|
|
1398
1435
|
# The running turn's metrics so far, saved as a turn a worker died in
|
|
1399
1436
|
# would leave them (SessionMetrics#persist's running_turn): a worker's
|
|
1400
1437
|
# turn checkpoint (TurnCheckpoint) calls it when it writes.
|
|
@@ -1500,14 +1537,15 @@ module Samagotchi
|
|
|
1500
1537
|
change
|
|
1501
1538
|
end
|
|
1502
1539
|
|
|
1503
|
-
# Reads the session's participants at a turn's start; the first to join
|
|
1504
|
-
#
|
|
1505
|
-
#
|
|
1540
|
+
# Reads the session's participants at a turn's start; the first to join,
|
|
1541
|
+
# the first invited to act (a pair participant: the line's words) and
|
|
1542
|
+
# the last to leave rebuild the system prompt (its participants line),
|
|
1543
|
+
# once each: a turn in between sends the same prefix.
|
|
1506
1544
|
def refresh_participants!(session)
|
|
1507
|
-
|
|
1508
|
-
return if
|
|
1545
|
+
line = @participants.refresh!(session).prompt_mode(session)
|
|
1546
|
+
return if line == @participants_line
|
|
1509
1547
|
|
|
1510
|
-
@participants_line =
|
|
1548
|
+
@participants_line = line
|
|
1511
1549
|
@prompt_builder.reset!
|
|
1512
1550
|
end
|
|
1513
1551
|
private :refresh_participants!
|
|
@@ -1527,8 +1565,9 @@ module Samagotchi
|
|
|
1527
1565
|
# A participant's opening message keeps the rules it was delivered under
|
|
1528
1566
|
# (ParticipantMode), stamped before any request carries it, so its header
|
|
1529
1567
|
# never changes when the session's mode does.
|
|
1530
|
-
def participant_mode_fields(author
|
|
1531
|
-
Steer.participant_author?(author)
|
|
1568
|
+
def participant_mode_fields(author)
|
|
1569
|
+
mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
|
|
1570
|
+
mode ? { participant_mode: mode.to_s } : {}
|
|
1532
1571
|
end
|
|
1533
1572
|
|
|
1534
1573
|
# Why a turn stopped (TurnEndings#stop_facts).
|
|
@@ -2139,11 +2178,15 @@ module Samagotchi
|
|
|
2139
2178
|
turn_started[:cut_after] = steer_cut_after
|
|
2140
2179
|
# A participant's turn names them (author, author_name) for the UIs'
|
|
2141
2180
|
# badge; the owner's turn_started stays as it was.
|
|
2142
|
-
turn_started.merge!(author_fields(turn.origin.author, session: session)
|
|
2181
|
+
turn_started.merge!(author_fields(turn.origin.author, session: session),
|
|
2182
|
+
participant_mode_fields(turn.origin.author))
|
|
2143
2183
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2144
2184
|
raise image_error if image_error
|
|
2145
2185
|
|
|
2146
|
-
|
|
2186
|
+
# On mode's ask starts here too; the turn joins it before
|
|
2187
|
+
# apply_toolkit! below, so its latency hides behind the probes and
|
|
2188
|
+
# the init wait.
|
|
2189
|
+
toolkit_suggestion = start_toolkit_suggestion(turn)
|
|
2147
2190
|
|
|
2148
2191
|
# The window and the profile probes run after run_turn's probe-cancel
|
|
2149
2192
|
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
@@ -2159,7 +2202,10 @@ module Samagotchi
|
|
|
2159
2202
|
refuse_images!(turn.settings.vision) unless image_refs.empty?
|
|
2160
2203
|
# A Stop during the probes: the turn ends before the load-failure
|
|
2161
2204
|
# warnings, which show once, so the next turn still shows them.
|
|
2162
|
-
|
|
2205
|
+
if turn.controller.cancelled?
|
|
2206
|
+
toolkit_suggestion&.abandon(:canceled)
|
|
2207
|
+
return false
|
|
2208
|
+
end
|
|
2163
2209
|
|
|
2164
2210
|
announce_guardrail_failures(turn.on_event)
|
|
2165
2211
|
# Plugins' slow setup that brings tools (an MCP server's first
|
|
@@ -2172,9 +2218,12 @@ module Samagotchi
|
|
|
2172
2218
|
# session's toolkit (a /toolkit change since the last turn): what the
|
|
2173
2219
|
# prompt built next declares, unchanged until the next turn's start.
|
|
2174
2220
|
apply_staged_tools!
|
|
2221
|
+
# On mode: the suggested kit, before anything is declared (a Stop
|
|
2222
|
+
# during the wait is caught by the check below).
|
|
2223
|
+
settle_toolkit_suggestion(turn, toolkit_suggestion)
|
|
2175
2224
|
apply_toolkit!(session, turn.on_event)
|
|
2176
2225
|
refresh_participants!(session)
|
|
2177
|
-
# A Stop during the
|
|
2226
|
+
# A Stop during the waits above: the turn ends here,
|
|
2178
2227
|
# before the hooks run or a reminder is used up.
|
|
2179
2228
|
return false if turn.controller.cancelled?
|
|
2180
2229
|
|
|
@@ -2191,6 +2240,10 @@ module Samagotchi
|
|
|
2191
2240
|
|
|
2192
2241
|
turn_messages(turn, image_refs)
|
|
2193
2242
|
true
|
|
2243
|
+
ensure
|
|
2244
|
+
# A turn that ended (a failure, an Interrupt) before it settled the
|
|
2245
|
+
# ask: its line says so. Nothing once it was settled.
|
|
2246
|
+
toolkit_suggestion&.abandon(:failed)
|
|
2194
2247
|
end
|
|
2195
2248
|
private :prepare_turn
|
|
2196
2249
|
|
|
@@ -2306,7 +2359,7 @@ module Samagotchi
|
|
|
2306
2359
|
|
|
2307
2360
|
unless turn.continue
|
|
2308
2361
|
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2309
|
-
**participant_mode_fields(turn.origin.author
|
|
2362
|
+
**participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
|
|
2310
2363
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2311
2364
|
turn.messages << user_message
|
|
2312
2365
|
session.last_prompt = turn.prompt
|
|
@@ -2729,20 +2782,22 @@ module Samagotchi
|
|
|
2729
2782
|
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
2730
2783
|
private :apply_toolkit!
|
|
2731
2784
|
|
|
2732
|
-
# Start
|
|
2733
|
-
#
|
|
2734
|
-
|
|
2785
|
+
# Start the toolkit classifier after :turn_started, for audit and on
|
|
2786
|
+
# mode alike (one eligibility, one snapshot, one thread). All session
|
|
2787
|
+
# and tool-registry reads happen here; the thread receives only this
|
|
2788
|
+
# snapshot. Audit never blocks: the thread logs its line. On mode
|
|
2789
|
+
# returns the ask's Pending, which the turn joins before apply_toolkit!
|
|
2790
|
+
# (#settle_toolkit_suggestion), with the turn's controller as the
|
|
2791
|
+
# request's cancel controller.
|
|
2792
|
+
# @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
|
|
2793
|
+
def start_toolkit_suggestion(turn)
|
|
2735
2794
|
mode = Config.get("toolkit.suggest.mode").to_s
|
|
2736
2795
|
return if mode == "off"
|
|
2737
2796
|
|
|
2797
|
+
joined = mode == "on"
|
|
2738
2798
|
session = turn.session
|
|
2739
2799
|
default = Toolkit.of(session.toolkit)
|
|
2740
|
-
|
|
2741
|
-
log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2742
|
-
warn: lambda { |reason|
|
|
2743
|
-
Log.warn(:toolkit, "toolkit_suggest_log_failed",
|
|
2744
|
-
echo: "[samagotchi:toolkit] #{reason}")
|
|
2745
|
-
})
|
|
2800
|
+
log = toolkit_suggestion_log
|
|
2746
2801
|
# The predicate checks the session first; the kits (memory kits are
|
|
2747
2802
|
# read from disk) and the log (read whole) only when it gets there.
|
|
2748
2803
|
kits = nil
|
|
@@ -2769,11 +2824,19 @@ module Samagotchi
|
|
|
2769
2824
|
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2770
2825
|
return unless client
|
|
2771
2826
|
|
|
2772
|
-
|
|
2827
|
+
# D7: on a shared single-slot server audit's free ask would compete
|
|
2828
|
+
# with the turn's prefill, and any ask evicts the session's cached
|
|
2829
|
+
# prefix: on mode asks only on a first turn there (nothing is cached
|
|
2830
|
+
# yet worth keeping), never on the deferred path.
|
|
2831
|
+
if (!joined || ToolkitSuggest.model_reply?(session.messages)) && toolkit_judge_shares_local_host?(client.host)
|
|
2773
2832
|
Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
|
|
2774
2833
|
return
|
|
2775
2834
|
end
|
|
2776
2835
|
|
|
2836
|
+
# The warm-up's only value is the cache: the real ask never queues
|
|
2837
|
+
# behind it.
|
|
2838
|
+
stop_toolkit_warmup
|
|
2839
|
+
|
|
2777
2840
|
# Rules, not tool lists: this runs before await_init_tasks and
|
|
2778
2841
|
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
2779
2842
|
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
@@ -2794,16 +2857,124 @@ module Samagotchi
|
|
|
2794
2857
|
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
2795
2858
|
)
|
|
2796
2859
|
threshold = Config.get("toolkit.suggest.threshold")
|
|
2860
|
+
pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
|
|
2861
|
+
timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
|
|
2862
|
+
# On mode: cut by a Stop while the turn waits (Pending#wait), not
|
|
2863
|
+
# by one after it went on; audit's ask is never cut.
|
|
2864
|
+
controller = joined ? pending.cancel_controller : nil
|
|
2797
2865
|
@toolkit_audit_thread = Thread.new do
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2866
|
+
judged = nil
|
|
2867
|
+
begin
|
|
2868
|
+
judged = ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
|
|
2869
|
+
cancel_controller: controller)
|
|
2870
|
+
rescue LLM::RequestCancelled
|
|
2871
|
+
nil
|
|
2872
|
+
rescue StandardError => e
|
|
2873
|
+
ToolkitSuggest.warn(e)
|
|
2874
|
+
ensure
|
|
2875
|
+
pending.finish(judged)
|
|
2876
|
+
end
|
|
2877
|
+
end
|
|
2878
|
+
joined ? pending : nil
|
|
2879
|
+
rescue StandardError => e
|
|
2880
|
+
ToolkitSuggest.warn(e)
|
|
2881
|
+
nil
|
|
2882
|
+
end
|
|
2883
|
+
private :start_toolkit_suggestion
|
|
2884
|
+
|
|
2885
|
+
# On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
|
|
2886
|
+
# suggestion) up to System 1's timeout from its start, or until a Stop,
|
|
2887
|
+
# and put the session on a candidate picked with confidence ≥ threshold
|
|
2888
|
+
# (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
|
|
2889
|
+
# other outcome (the default, below threshold, undecided, no judgment,
|
|
2890
|
+
# a timeout, a Stop) leaves the session on its default. Nothing here
|
|
2891
|
+
# sends a request; prepare_turn's cancel check ends a stopped turn. A
|
|
2892
|
+
# Stop wins over an answer already in (Pending#wait checks it first).
|
|
2893
|
+
def settle_toolkit_suggestion(turn, pending)
|
|
2894
|
+
return if pending.nil?
|
|
2895
|
+
|
|
2896
|
+
outcome, judged = pending.wait(turn.controller)
|
|
2897
|
+
unless outcome == :answered
|
|
2898
|
+
Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
|
|
2899
|
+
return
|
|
2900
|
+
end
|
|
2901
|
+
return if judged.nil?
|
|
2902
|
+
|
|
2903
|
+
pending.record(toolkit_suggestion_outcome(turn.session, judged))
|
|
2904
|
+
rescue StandardError => e
|
|
2905
|
+
ToolkitSuggest.warn(e)
|
|
2906
|
+
pending&.record("failed")
|
|
2907
|
+
end
|
|
2908
|
+
private :settle_toolkit_suggestion
|
|
2909
|
+
|
|
2910
|
+
# What the turn does with an answered ask, as the log line's did:
|
|
2911
|
+
# suggested (switched), chosen (the toolkit isn't the default any
|
|
2912
|
+
# more), unavailable (the kit is gone), defer or default.
|
|
2913
|
+
def toolkit_suggestion_outcome(session, judged)
|
|
2914
|
+
case judged.would
|
|
2915
|
+
when "suggested"
|
|
2916
|
+
return "chosen" unless session.toolkit_origin == "default"
|
|
2917
|
+
return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
|
|
2918
|
+
|
|
2919
|
+
switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
|
|
2920
|
+
deferred: ToolkitSuggest.model_reply?(session.messages))
|
|
2921
|
+
switched ? "suggested" : "chosen"
|
|
2922
|
+
when "defer" then "defer"
|
|
2923
|
+
else "default"
|
|
2924
|
+
end
|
|
2925
|
+
end
|
|
2926
|
+
private :toolkit_suggestion_outcome
|
|
2927
|
+
|
|
2928
|
+
# On mode, as an idle worker starts (Worker#start_background): one ask
|
|
2929
|
+
# with the real question and a stand-in prompt, on its own thread,
|
|
2930
|
+
# never logged and never waited for, so System 1's server has the
|
|
2931
|
+
# question's prefix cached for the first prompt. Only for a session a
|
|
2932
|
+
# first prompt could be asked for: origin default, no model reply, not
|
|
2933
|
+
# a delegate child or a review session, at least two candidates; and
|
|
2934
|
+
# not when the worker starts on a first prompt (that ask comes at once).
|
|
2935
|
+
def warm_toolkit_suggestion!
|
|
2936
|
+
return unless Config.get("toolkit.suggest.mode").to_s == "on"
|
|
2937
|
+
|
|
2938
|
+
session = @session
|
|
2939
|
+
return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
|
|
2940
|
+
return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
|
|
2941
|
+
|
|
2942
|
+
default = Toolkit.of(session.toolkit)
|
|
2943
|
+
kits = @toolkit_binding.kits
|
|
2944
|
+
return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
|
|
2945
|
+
|
|
2946
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2947
|
+
return unless client
|
|
2948
|
+
# D7: on the model's own single-slot server it would evict the
|
|
2949
|
+
# session's cached prefix for a question that may never come.
|
|
2950
|
+
return if toolkit_judge_shares_local_host?(client.host)
|
|
2951
|
+
|
|
2952
|
+
project = File.basename(session.project_root.to_s)
|
|
2953
|
+
@toolkit_warmup_thread = Thread.new do
|
|
2954
|
+
ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
|
|
2801
2955
|
end
|
|
2802
2956
|
rescue StandardError => e
|
|
2803
2957
|
ToolkitSuggest.warn(e)
|
|
2804
2958
|
nil
|
|
2805
2959
|
end
|
|
2806
|
-
|
|
2960
|
+
public :warm_toolkit_suggestion!
|
|
2961
|
+
|
|
2962
|
+
# A still-running warm-up, stopped: a real ask (or the worker leaving)
|
|
2963
|
+
# doesn't wait behind it.
|
|
2964
|
+
def stop_toolkit_warmup
|
|
2965
|
+
@toolkit_warmup_thread&.kill
|
|
2966
|
+
@toolkit_warmup_thread = nil
|
|
2967
|
+
end
|
|
2968
|
+
private :stop_toolkit_warmup
|
|
2969
|
+
|
|
2970
|
+
def toolkit_suggestion_log
|
|
2971
|
+
path = ToolkitSuggest.log_path(session_state_dir)
|
|
2972
|
+
SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2973
|
+
warn: lambda { |reason|
|
|
2974
|
+
Log.warn(:toolkit, "toolkit_suggest_log_failed", echo: "[samagotchi:toolkit] #{reason}")
|
|
2975
|
+
})
|
|
2976
|
+
end
|
|
2977
|
+
private :toolkit_suggestion_log
|
|
2807
2978
|
|
|
2808
2979
|
# D7: System 1's host is the session's model server, and a local one
|
|
2809
2980
|
# (a single-slot Splash or llama.cpp): the same name, or two hosts:
|
|
@@ -5,6 +5,7 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "rbconfig"
|
|
7
7
|
require "rubygems/package"
|
|
8
|
+
require "time"
|
|
8
9
|
require_relative "version"
|
|
9
10
|
require_relative "redacted_inspect"
|
|
10
11
|
|
|
@@ -22,12 +23,14 @@ module Samagotchi
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
LATEST_URL = URI("https://rubygems.org/api/v1/versions/samagotchi/latest.json")
|
|
26
|
+
RELEASES_URL = URI("https://rubygems.org/api/v1/versions/samagotchi.json")
|
|
25
27
|
# Not SAMAGOTCHI_UPDATE_GEM: that is the env form of config update.gem.
|
|
26
28
|
LOCAL_ENV = "SAMAGOTCHI_UPDATE_GEM_FILE"
|
|
27
29
|
TIMEOUT = 5
|
|
28
30
|
|
|
29
31
|
# source is what `gem install` takes: the gem's name, or a .gem file.
|
|
30
32
|
Latest = Struct.new(:version, :source, keyword_init: true)
|
|
33
|
+
Release = Data.define(:version, :created_at, :prerelease)
|
|
31
34
|
|
|
32
35
|
# Runs a command without a shell; [output, success].
|
|
33
36
|
class Runner
|
|
@@ -57,7 +60,7 @@ module Samagotchi
|
|
|
57
60
|
local = @env[LOCAL_ENV].to_s
|
|
58
61
|
return Latest.new(version: Gem::Package.new(local).spec.version.to_s, source: local) unless local.empty?
|
|
59
62
|
|
|
60
|
-
version = JSON.parse(
|
|
63
|
+
version = JSON.parse(fetch_body(LATEST_URL).to_s)["version"].to_s
|
|
61
64
|
raise Error, "rubygems.org answered without a version" if version.empty? || !Gem::Version.correct?(version)
|
|
62
65
|
|
|
63
66
|
Latest.new(version: version, source: "samagotchi")
|
|
@@ -67,6 +70,29 @@ module Samagotchi
|
|
|
67
70
|
raise Error, "#{e.class}: #{e.message}".lines.first.strip
|
|
68
71
|
end
|
|
69
72
|
|
|
73
|
+
# @return [Array<Release>] rubygems releases, or the one local smoke gem
|
|
74
|
+
def releases
|
|
75
|
+
local = @env[LOCAL_ENV].to_s
|
|
76
|
+
return [local_release(local)] unless local.empty?
|
|
77
|
+
|
|
78
|
+
records = JSON.parse(fetch_body(RELEASES_URL).to_s)
|
|
79
|
+
raise Error, "rubygems.org answered without a releases list" unless records.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
records.map do |record|
|
|
82
|
+
version = record["number"].to_s
|
|
83
|
+
created_at = record["created_at"].to_s
|
|
84
|
+
unless Gem::Version.correct?(version) && !created_at.empty? && [true, false].include?(record["prerelease"])
|
|
85
|
+
raise Error, "rubygems.org answered with an invalid release"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Release.new(version: version, created_at: Time.iso8601(created_at), prerelease: record["prerelease"])
|
|
89
|
+
end
|
|
90
|
+
rescue Error
|
|
91
|
+
raise
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
raise Error, "#{e.class}: #{e.message}".lines.first.strip
|
|
94
|
+
end
|
|
95
|
+
|
|
70
96
|
def newer?(latest, current = VERSION)
|
|
71
97
|
Gem::Version.new(latest.version) > Gem::Version.new(current)
|
|
72
98
|
end
|
|
@@ -83,9 +109,20 @@ module Samagotchi
|
|
|
83
109
|
|
|
84
110
|
private
|
|
85
111
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
def local_release(path)
|
|
113
|
+
version = Gem::Package.new(path).spec.version
|
|
114
|
+
Release.new(version: version.to_s, created_at: File.mtime(path), prerelease: version.prerelease?)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fetch_body(url)
|
|
118
|
+
return @fetcher.call if @fetcher.arity.zero?
|
|
119
|
+
|
|
120
|
+
@fetcher.call(url)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def fetch(url = LATEST_URL)
|
|
124
|
+
Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
125
|
+
request = Net::HTTP::Get.new(url)
|
|
89
126
|
request["User-Agent"] = USER_AGENT
|
|
90
127
|
response = http.request(request)
|
|
91
128
|
raise Error, "rubygems.org answered #{response.code}" unless response.code == "200"
|