samagotchi 0.152.0 → 0.154.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 +45 -1
- data/README.md +2 -3
- data/bin/chi +23 -24
- data/docs/architecture.md +30 -33
- data/docs/broadcast.md +5 -5
- data/docs/bundle-authoring.md +1 -1
- data/docs/bundles.md +93 -12
- data/docs/cli.md +83 -99
- data/docs/configuration.md +46 -40
- data/docs/context.md +3 -3
- data/docs/desktop.md +1 -1
- data/docs/guardrails.md +15 -15
- data/docs/hooks.md +13 -12
- data/docs/internals/anthropic.md +3 -3
- data/docs/internals/attached-context.md +5 -2
- data/docs/internals/background-tasks.md +3 -4
- data/docs/internals/block-renderers.md +4 -0
- data/docs/internals/bridge.md +3 -3
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +42 -12
- data/docs/internals/objectives.md +6 -5
- data/docs/internals/participants.md +8 -8
- data/docs/internals/prompt-caching.md +8 -4
- data/docs/internals/remote-sessions.md +7 -7
- data/docs/internals/web-frontend.md +20 -17
- data/docs/internals/web-push.md +1 -1
- data/docs/memory.md +2 -2
- data/docs/plugins.md +52 -43
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +25 -25
- data/docs/sub-agent.md +2 -2
- data/docs/system-one.md +1 -1
- data/docs/testing.md +18 -8
- data/lib/samagotchi/answer_text.rb +34 -0
- data/lib/samagotchi/bridge.rb +43 -32
- data/lib/samagotchi/bridge_client.rb +10 -9
- data/lib/samagotchi/broadcast/recipients.rb +2 -2
- data/lib/samagotchi/broadcast/triage.rb +1 -1
- data/lib/samagotchi/broadcast_command.rb +5 -5
- data/lib/samagotchi/bundle_command/try/command.rb +1 -1
- data/lib/samagotchi/bundle_command/try/run.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
- data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
- data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
- data/lib/samagotchi/child_move.rb +3 -3
- data/lib/samagotchi/commands/registry.rb +6 -6
- data/lib/samagotchi/composer_route.rb +4 -5
- data/lib/samagotchi/config.rb +14 -12
- data/lib/samagotchi/context_candidate_choice.rb +1 -1
- data/lib/samagotchi/context_candidates.rb +4 -3
- data/lib/samagotchi/continue_offer.rb +1 -1
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/degrade.rb +75 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
- data/lib/samagotchi/engine/cards.rb +87 -0
- data/lib/samagotchi/engine/context_judge.rb +138 -0
- data/lib/samagotchi/engine/events.rb +131 -0
- data/lib/samagotchi/engine/guardrails.rb +87 -0
- data/lib/samagotchi/engine/hooks.rb +119 -0
- data/lib/samagotchi/engine/messages.rb +124 -0
- data/lib/samagotchi/engine/model_settings.rb +127 -0
- data/lib/samagotchi/engine/models.rb +261 -0
- data/lib/samagotchi/engine/objectives.rb +116 -0
- data/lib/samagotchi/engine/participants.rb +86 -0
- data/lib/samagotchi/engine/plugins.rb +208 -0
- data/lib/samagotchi/engine/questions.rb +98 -0
- data/lib/samagotchi/engine/recap.rb +130 -0
- data/lib/samagotchi/engine/session.rb +131 -0
- data/lib/samagotchi/engine/side_requests.rb +135 -0
- data/lib/samagotchi/engine/steering.rb +134 -0
- data/lib/samagotchi/engine/toolkit.rb +269 -0
- data/lib/samagotchi/engine/turn_end.rb +180 -0
- data/lib/samagotchi/engine/turn_helpers.rb +160 -0
- data/lib/samagotchi/engine/verify.rb +153 -0
- data/lib/samagotchi/engine.rb +63 -2642
- data/lib/samagotchi/grants.rb +575 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -2
- data/lib/samagotchi/guardrails/context.rb +2 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
- data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
- data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
- data/lib/samagotchi/hook_routes.rb +3 -3
- data/lib/samagotchi/idle_recap.rb +4 -5
- data/lib/samagotchi/idle_reminders.rb +3 -4
- data/lib/samagotchi/iteration_limit.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +10 -11
- data/lib/samagotchi/launch_mode.rb +13 -32
- data/lib/samagotchi/level_setting_command.rb +3 -2
- data/lib/samagotchi/llm_context_command.rb +3 -2
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/log.rb +7 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_path.rb +1 -1
- data/lib/samagotchi/message_parts.rb +3 -2
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +18 -8
- data/lib/samagotchi/objectives.rb +24 -3
- data/lib/samagotchi/objectives_check.rb +8 -27
- data/lib/samagotchi/objectives_command.rb +4 -69
- data/lib/samagotchi/objectives_edit.rb +87 -0
- data/lib/samagotchi/output_formatter.rb +7 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/parent_answers_command.rb +5 -4
- data/lib/samagotchi/participant_exposure.rb +42 -0
- data/lib/samagotchi/participant_mode.rb +1 -1
- data/lib/samagotchi/participant_name.rb +1 -1
- data/lib/samagotchi/participants.rb +17 -17
- data/lib/samagotchi/participants_watch.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +2 -2
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/context.rb +21 -7
- data/lib/samagotchi/plugin/peer.rb +2 -1
- data/lib/samagotchi/plugin/sessions.rb +6 -4
- data/lib/samagotchi/plugin_tasks.rb +1 -2
- data/lib/samagotchi/private_files.rb +29 -0
- data/lib/samagotchi/project_env.rb +2 -2
- data/lib/samagotchi/question_desk.rb +8 -7
- data/lib/samagotchi/recap_command.rb +2 -1
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/recap_store.rb +1 -1
- data/lib/samagotchi/redact.rb +86 -0
- data/lib/samagotchi/reminder_queue.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +2 -2
- data/lib/samagotchi/review_start.rb +1 -1
- data/lib/samagotchi/run_options.rb +6 -6
- data/lib/samagotchi/self_report.rb +42 -39
- data/lib/samagotchi/send_command/busy.rb +1 -1
- data/lib/samagotchi/send_command.rb +4 -1
- data/lib/samagotchi/session.rb +11 -2
- data/lib/samagotchi/session_access/contract.rb +3 -3
- data/lib/samagotchi/session_access/local.rb +9 -9
- data/lib/samagotchi/session_access/remote.rb +17 -7
- data/lib/samagotchi/session_access.rb +4 -4
- data/lib/samagotchi/session_archive_command.rb +2 -2
- data/lib/samagotchi/session_chain.rb +8 -6
- data/lib/samagotchi/session_commands.rb +45 -48
- data/lib/samagotchi/session_continue.rb +1 -1
- data/lib/samagotchi/session_delete_command.rb +4 -4
- data/lib/samagotchi/session_manager.rb +59 -36
- data/lib/samagotchi/session_metrics.rb +2 -2
- data/lib/samagotchi/session_model_settings.rb +3 -2
- data/lib/samagotchi/session_participants.rb +1 -1
- data/lib/samagotchi/session_retention.rb +3 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/share_command.rb +10 -10
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/system_prompt.rb +102 -109
- data/lib/samagotchi/task_watch.rb +1 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
- data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
- data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
- data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
- data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
- data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
- data/lib/samagotchi/terminal_ui.rb +142 -1387
- data/lib/samagotchi/title_command.rb +2 -2
- data/lib/samagotchi/tool_runner.rb +14 -16
- data/lib/samagotchi/toolkit.rb +8 -5
- data/lib/samagotchi/toolkit_binding.rb +30 -5
- data/lib/samagotchi/toolkit_command.rb +2 -1
- data/lib/samagotchi/toolkit_report_command.rb +9 -5
- data/lib/samagotchi/toolkit_suggest.rb +12 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -10
- data/lib/samagotchi/tools/deferred_tools.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +3 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/objectives_tool.rb +2 -2
- data/lib/samagotchi/tools/registry.rb +44 -20
- data/lib/samagotchi/tools/send_note.rb +2 -1
- data/lib/samagotchi/tools/tool_index.rb +27 -21
- data/lib/samagotchi/turn_endings.rb +2 -2
- data/lib/samagotchi/turn_flow.rb +4 -5
- data/lib/samagotchi/update_hint.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +39 -50
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_review.rb +5 -5
- data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
- data/lib/samagotchi/web/participant_view.rb +2 -82
- data/lib/samagotchi/web/participants_admin.rb +1 -1
- data/lib/samagotchi/web/public/app.js +61 -357
- data/lib/samagotchi/web/public/block_host.js +35 -8
- data/lib/samagotchi/web/public/block_renderers.css +9 -5
- data/lib/samagotchi/web/public/chains.js +2 -2
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer_mode.js +110 -0
- data/lib/samagotchi/web/public/format.js +3 -3
- data/lib/samagotchi/web/public/owner_claim.js +81 -0
- data/lib/samagotchi/web/public/owner_verify.js +112 -0
- data/lib/samagotchi/web/public/session_fields.js +3 -3
- data/lib/samagotchi/web/public/strip_grip.js +3 -3
- data/lib/samagotchi/web/public/strip_hidden.js +65 -0
- data/lib/samagotchi/web/public/version_notices.js +132 -0
- data/lib/samagotchi/web/push/store.rb +1 -1
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +12 -8
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +2 -2
- data/lib/samagotchi/web/session_show.rb +2 -2
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +106 -9
- data/lib/samagotchi/worker_link.rb +3 -3
- data/lib/samagotchi/working_set_command.rb +3 -2
- metadata +36 -6
- data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
- data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
- data/lib/samagotchi/web/grants.rb +0 -575
- data/lib/samagotchi/web/participant_exposure.rb +0 -44
- data/lib/samagotchi/web/private_files.rb +0 -31
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "openssl"
|
|
|
9
9
|
require "time"
|
|
10
10
|
require_relative "atomic_file"
|
|
11
11
|
require_relative "bridge_secret"
|
|
12
|
+
require_relative "degrade"
|
|
12
13
|
require_relative "delivery"
|
|
13
14
|
require_relative "session_inbox"
|
|
14
15
|
require_relative "turn_anchors"
|
|
@@ -156,6 +157,9 @@ module Samagotchi
|
|
|
156
157
|
# Open streams by the client_id a stream named (`?client_id=`); web
|
|
157
158
|
# tabs, through the `chi web` proxy, name none.
|
|
158
159
|
@streams_by_client = Hash.new(0)
|
|
160
|
+
# Streams served since the start, open or closed (a scratch worker
|
|
161
|
+
# leaves once its terminal's has gone: Worker#scratch_unattended?).
|
|
162
|
+
@streams_served = 0
|
|
159
163
|
@last_client_activity_at = monotonic_now
|
|
160
164
|
end
|
|
161
165
|
|
|
@@ -178,6 +182,11 @@ module Samagotchi
|
|
|
178
182
|
@mutex.synchronize { @open_streams }
|
|
179
183
|
end
|
|
180
184
|
|
|
185
|
+
# @return [Integer] SSE streams served since the Bridge started
|
|
186
|
+
def streams_served
|
|
187
|
+
@mutex.synchronize { @streams_served }
|
|
188
|
+
end
|
|
189
|
+
|
|
181
190
|
# Streams held by anyone but +client_id+: a client's own stream (or two,
|
|
182
191
|
# while it reconnects and the old one isn't noticed dead yet) doesn't
|
|
183
192
|
# count. A closed stream stops counting once its writer reads the
|
|
@@ -236,7 +245,7 @@ module Samagotchi
|
|
|
236
245
|
@mutex.synchronize { @stopped = true }
|
|
237
246
|
begin
|
|
238
247
|
@server&.close
|
|
239
|
-
rescue StandardError
|
|
248
|
+
rescue StandardError # degrade-ok: stopping: a server socket already closed is fine
|
|
240
249
|
nil
|
|
241
250
|
end
|
|
242
251
|
wake_acceptor
|
|
@@ -254,7 +263,7 @@ module Samagotchi
|
|
|
254
263
|
@mutex.synchronize { @stopped = true }
|
|
255
264
|
begin
|
|
256
265
|
@server&.close
|
|
257
|
-
rescue StandardError
|
|
266
|
+
rescue StandardError # degrade-ok: stopping: a server socket already closed is fine
|
|
258
267
|
nil
|
|
259
268
|
end
|
|
260
269
|
wake_acceptor
|
|
@@ -268,13 +277,13 @@ module Samagotchi
|
|
|
268
277
|
# the web, which then queues the prompt again from the input file.
|
|
269
278
|
await_answers(REQUEST_GRACE_SECONDS)
|
|
270
279
|
remove_sidecar
|
|
271
|
-
@connection_threads.each { |t| t.kill rescue nil }
|
|
280
|
+
@connection_threads.each { |t| t.kill rescue nil } # degrade-ok: stopping: a thread already gone is fine
|
|
272
281
|
@connection_threads.clear
|
|
273
282
|
# Its relay watchers (RelayWatcher) end with it.
|
|
274
283
|
watchers = @mutex.synchronize { (@relay_watchers || []).tap { @relay_watchers = [] } }
|
|
275
284
|
watchers.each { |thread| thread.kill.join(RELAY_WATCHER_JOIN_SECONDS) }
|
|
276
285
|
@accept_thread&.join(2)
|
|
277
|
-
[@wake_reader, @wake_writer].each { |io| io&.close rescue nil }
|
|
286
|
+
[@wake_reader, @wake_writer].each { |io| io&.close rescue nil } # degrade-ok: stopping: a closed pipe is fine
|
|
278
287
|
nil
|
|
279
288
|
end
|
|
280
289
|
|
|
@@ -399,9 +408,10 @@ module Samagotchi
|
|
|
399
408
|
# O(1) and non-blocking.
|
|
400
409
|
def capture_observer
|
|
401
410
|
@capture_proc ||= proc do |event|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
411
|
+
# Per event: the first failure warns, the rest log at debug.
|
|
412
|
+
Degrade.call(:bridge, "event_capture_failed") do
|
|
413
|
+
@ring.push(seq: event[:event_seq], data: event)
|
|
414
|
+
end
|
|
405
415
|
end
|
|
406
416
|
end
|
|
407
417
|
|
|
@@ -527,7 +537,7 @@ module Samagotchi
|
|
|
527
537
|
Thread.current[:bridge_answering] = false
|
|
528
538
|
begin
|
|
529
539
|
io.close
|
|
530
|
-
rescue StandardError
|
|
540
|
+
rescue StandardError # degrade-ok: the client may be gone already; closing is all that's left
|
|
531
541
|
nil
|
|
532
542
|
end
|
|
533
543
|
end
|
|
@@ -555,6 +565,7 @@ module Samagotchi
|
|
|
555
565
|
)
|
|
556
566
|
@mutex.synchronize do
|
|
557
567
|
@open_streams += 1
|
|
568
|
+
@streams_served += 1
|
|
558
569
|
@streams_by_client[client_id] += 1 if client_id
|
|
559
570
|
end
|
|
560
571
|
begin
|
|
@@ -860,7 +871,7 @@ module Samagotchi
|
|
|
860
871
|
end
|
|
861
872
|
end
|
|
862
873
|
|
|
863
|
-
# Dismiss the pending question (an empty answer
|
|
874
|
+
# Dismiss the pending question (an empty answer at a terminal): the
|
|
864
875
|
# tool returns without an answer and every UI gets :question_cancelled.
|
|
865
876
|
# Only the question the client saw, and only while nobody has answered
|
|
866
877
|
# it: Engine#cancel_question checks both under the question lock. A past
|
|
@@ -1425,44 +1436,44 @@ module Samagotchi
|
|
|
1425
1436
|
# else fall back to the explicit `?from_seq=` query param used by clients
|
|
1426
1437
|
# that remember their own cursor.
|
|
1427
1438
|
def reconnect_cursor(headers, query)
|
|
1428
|
-
|
|
1429
|
-
|
|
1439
|
+
Degrade.call(:bridge, "sse_cursor_unparsable") do
|
|
1440
|
+
cursor = headers["last-event-id"]
|
|
1441
|
+
return cursor if cursor && !cursor.empty?
|
|
1430
1442
|
|
|
1431
|
-
|
|
1443
|
+
return nil unless query
|
|
1432
1444
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
nil
|
|
1445
|
+
URI.decode_www_form(query).to_h["from_seq"]
|
|
1446
|
+
end
|
|
1436
1447
|
end
|
|
1437
1448
|
|
|
1438
1449
|
# `?snapshot=1`: join with a snapshot frame instead of a replay.
|
|
1439
1450
|
def snapshot_requested?(query)
|
|
1440
|
-
|
|
1451
|
+
Degrade.call(:bridge, "snapshot_query_unparsable", fallback: false) do
|
|
1452
|
+
return false unless query
|
|
1441
1453
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
false
|
|
1454
|
+
URI.decode_www_form(query).to_h["snapshot"].to_s == "1"
|
|
1455
|
+
end
|
|
1445
1456
|
end
|
|
1446
1457
|
|
|
1447
1458
|
# `?raw=1`: events as the engine emitted them (a chunk's provider
|
|
1448
1459
|
# payload), not as clients get them (ClientEvent). For debugging on the
|
|
1449
1460
|
# worker's port: chi web never passes it on.
|
|
1450
1461
|
def raw_requested?(query)
|
|
1451
|
-
|
|
1462
|
+
Degrade.call(:bridge, "raw_query_unparsable", fallback: false) do
|
|
1463
|
+
return false unless query
|
|
1452
1464
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
false
|
|
1465
|
+
URI.decode_www_form(query).to_h["raw"].to_s == "1"
|
|
1466
|
+
end
|
|
1456
1467
|
end
|
|
1457
1468
|
|
|
1458
1469
|
# `?client_id=`: whose stream it is (see #open_streams_except).
|
|
1459
1470
|
def stream_client_id(query)
|
|
1460
|
-
|
|
1471
|
+
Degrade.call(:bridge, "client_id_query_unparsable") do
|
|
1472
|
+
return nil unless query
|
|
1461
1473
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
nil
|
|
1474
|
+
id = URI.decode_www_form(query).to_h["client_id"].to_s
|
|
1475
|
+
id.empty? ? nil : id
|
|
1476
|
+
end
|
|
1466
1477
|
end
|
|
1467
1478
|
|
|
1468
1479
|
def close_after_request?(headers)
|
|
@@ -1534,11 +1545,11 @@ module Samagotchi
|
|
|
1534
1545
|
# same session may have written its own since (a file without an epoch
|
|
1535
1546
|
# is only ever an older worker's).
|
|
1536
1547
|
def remove_sidecar
|
|
1537
|
-
|
|
1548
|
+
Degrade.call(:bridge, "sidecar_remove_failed") do
|
|
1549
|
+
return unless @server
|
|
1538
1550
|
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
nil
|
|
1551
|
+
File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.epoch == @epoch.to_s
|
|
1552
|
+
end
|
|
1542
1553
|
end
|
|
1543
1554
|
|
|
1544
1555
|
def session_dir
|
|
@@ -4,6 +4,7 @@ require "digest"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "socket"
|
|
6
6
|
|
|
7
|
+
require_relative "degrade"
|
|
7
8
|
require_relative "worker_sidecar"
|
|
8
9
|
require_relative "bridge_secret"
|
|
9
10
|
require_relative "delivery"
|
|
@@ -329,17 +330,17 @@ module Samagotchi
|
|
|
329
330
|
body = response.split("\r\n\r\n", 2)[1] || ""
|
|
330
331
|
Response.new(status: status.to_i, body: body)
|
|
331
332
|
ensure
|
|
332
|
-
sock&.close rescue nil
|
|
333
|
+
sock&.close rescue nil # degrade-ok: done with the socket; one already closed is fine
|
|
333
334
|
end
|
|
334
335
|
|
|
335
336
|
# Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
|
|
336
337
|
# @return [Integer, nil]
|
|
337
338
|
def event_seq
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
339
|
+
Degrade.call(:bridge, "event_seq_unreadable") do
|
|
340
|
+
state = get_json("state")
|
|
341
|
+
seq = state && state.dig("session_state_snapshot", "event_seq")
|
|
342
|
+
seq.nil? ? nil : seq.to_i
|
|
343
|
+
end
|
|
343
344
|
end
|
|
344
345
|
|
|
345
346
|
# Open GET /session/:id/stream and classify the response before the
|
|
@@ -358,7 +359,7 @@ module Samagotchi
|
|
|
358
359
|
if connection
|
|
359
360
|
sock, status, headers = connection
|
|
360
361
|
unless !block_given? || yield(sock)
|
|
361
|
-
sock.close rescue nil
|
|
362
|
+
sock.close rescue nil # degrade-ok: the stream is given up; a socket already closed is fine
|
|
362
363
|
return StreamOpen::Unreachable.new(attempts: count)
|
|
363
364
|
end
|
|
364
365
|
return StreamOpen.classify(sock, status, headers)
|
|
@@ -389,7 +390,7 @@ module Samagotchi
|
|
|
389
390
|
break
|
|
390
391
|
end
|
|
391
392
|
ensure
|
|
392
|
-
sock&.close rescue nil
|
|
393
|
+
sock&.close rescue nil # degrade-ok: done with the socket; one already closed is fine
|
|
393
394
|
end
|
|
394
395
|
end
|
|
395
396
|
|
|
@@ -435,7 +436,7 @@ module Samagotchi
|
|
|
435
436
|
body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
|
|
436
437
|
Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
|
|
437
438
|
ensure
|
|
438
|
-
sock&.close rescue nil
|
|
439
|
+
sock&.close rescue nil # degrade-ok: done with the socket; one already closed is fine
|
|
439
440
|
end
|
|
440
441
|
|
|
441
442
|
# The reply up to the Bridge's close (or its first line only), within
|
|
@@ -12,7 +12,7 @@ module Samagotchi
|
|
|
12
12
|
# A session a broadcast may concern.
|
|
13
13
|
# @!attribute project [String, nil] its git project's root
|
|
14
14
|
# @!attribute live [Boolean] a worker runs it now
|
|
15
|
-
# @!attribute owner [String, nil] "worker", "tui" (a chi
|
|
15
|
+
# @!attribute owner [String, nil] "worker", "tui" (a one-turn chi run, which
|
|
16
16
|
# takes no notes) or nil
|
|
17
17
|
Recipient = Data.define(:id, :short_id, :project, :cwd, :desc, :live, :owner) do
|
|
18
18
|
def repl? = owner == "tui"
|
|
@@ -20,7 +20,7 @@ module Samagotchi
|
|
|
20
20
|
|
|
21
21
|
# Who a broadcast goes to: the user's own sessions, not delegate
|
|
22
22
|
# children (their parent relays what concerns them) and not scratch
|
|
23
|
-
# ones, that a worker or a chi
|
|
23
|
+
# ones, that a worker or a one-turn chi run runs now or that ended a turn in
|
|
24
24
|
# the last +active_hours+. Forks count: they are the user's sessions.
|
|
25
25
|
# Archived sessions don't; test runs only when chi runs as one.
|
|
26
26
|
module Recipients
|
|
@@ -23,7 +23,7 @@ module Samagotchi
|
|
|
23
23
|
# @!attribute by [String] who decided: "tags" (a shared tag),
|
|
24
24
|
# "model", "fallback" (delivered unchecked: deadline, error, an
|
|
25
25
|
# answer that is no yes or no), "scope" (the note's first line
|
|
26
|
-
# names another project), "all" (--all) or "repl" (a chi
|
|
26
|
+
# names another project), "all" (--all) or "repl" (a one-turn chi run owns
|
|
27
27
|
# it: no notes)
|
|
28
28
|
# @!attribute match [Match, nil] the shared tag (by "tags")
|
|
29
29
|
Verdict = Data.define(:relevant, :p, :reason, :by, :match) do
|
|
@@ -46,7 +46,7 @@ module Samagotchi
|
|
|
46
46
|
note from "broadcast": background the model sees on its next turn,
|
|
47
47
|
not a prompt. It starts no turn.
|
|
48
48
|
Recipients: your own sessions (not delegate children, not scratch
|
|
49
|
-
ones) that a worker or a chi
|
|
49
|
+
ones) that a worker or a one-turn chi run runs now, or that ended a turn in
|
|
50
50
|
the last broadcast.active_hours (8). One gets the note when it shares
|
|
51
51
|
a tag with it: a ticket id (PAY-123) in its branch or your prompts
|
|
52
52
|
there, a pull request (PR #42, its URL) attached to it, a link in
|
|
@@ -190,13 +190,13 @@ module Samagotchi
|
|
|
190
190
|
corrections: []), decisions, cards)
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
# Every recipient's verdict: a chi
|
|
193
|
+
# Every recipient's verdict: a one-turn chi run takes no notes, --all takes the
|
|
194
194
|
# rest, else Broadcast::Triage (tags, the scope line, the model).
|
|
195
195
|
# @return [Array<Decision>] in +recipients+' order
|
|
196
196
|
def decide(text, recipients, cards, note_tags, all:)
|
|
197
197
|
fixed = recipients.to_h do |r|
|
|
198
198
|
verdict = if r.repl?
|
|
199
|
-
Broadcast::Triage::Verdict.new(relevant: false, p: nil, reason:
|
|
199
|
+
Broadcast::Triage::Verdict.new(relevant: false, p: nil, reason: OwnerLock::TUI_BUSY, by: "repl")
|
|
200
200
|
elsif all
|
|
201
201
|
Broadcast::Triage::Verdict.new(relevant: true, p: nil, reason: "--all", by: "all")
|
|
202
202
|
end
|
|
@@ -254,7 +254,7 @@ module Samagotchi
|
|
|
254
254
|
|
|
255
255
|
result = NoteDelivery.deliver(d.recipient.id, text: body(broadcast.text, d.verdict), source: SOURCE,
|
|
256
256
|
state_dir: @state_dir)
|
|
257
|
-
next [d, "skipped",
|
|
257
|
+
next [d, "skipped", OwnerLock::TUI_BUSY] unless result.delivered?
|
|
258
258
|
|
|
259
259
|
[d, "delivered", "#{d.verdict.reason}#{"; waits for its next start" if result.status == :waits}"]
|
|
260
260
|
rescue SessionInbox::NoteRejected, SystemCallError => e
|
|
@@ -369,7 +369,7 @@ module Samagotchi
|
|
|
369
369
|
|
|
370
370
|
result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir)
|
|
371
371
|
unless result.delivered?
|
|
372
|
-
@stdout.puts(line_for(id, "skipped",
|
|
372
|
+
@stdout.puts(line_for(id, "skipped", OwnerLock::TUI_BUSY))
|
|
373
373
|
return Broadcast::TriageLog::Delivery.new(session: id, result: "refused")
|
|
374
374
|
end
|
|
375
375
|
|
|
@@ -42,7 +42,7 @@ module Samagotchi
|
|
|
42
42
|
f.switch "--json"
|
|
43
43
|
f.switch "--keep"
|
|
44
44
|
f.value "--timeout"
|
|
45
|
-
f.refuse "--attach", "
|
|
45
|
+
f.refuse "--attach", "chi bundle try can't use --attach"
|
|
46
46
|
end
|
|
47
47
|
DEFAULT_TIMEOUT = 300
|
|
48
48
|
# The sandbox's cost-cap bundle setting (--set cost-cap.cap=N).
|
|
@@ -30,7 +30,7 @@ module Samagotchi
|
|
|
30
30
|
|
|
31
31
|
def run(prompt:, options:, timeout:)
|
|
32
32
|
command = [RbConfig.ruby, "-I", Sandbox::LIB_DIR, Sandbox::CHI,
|
|
33
|
-
"--
|
|
33
|
+
"--non-interactive", "-p", prompt, *child_options(options)]
|
|
34
34
|
trap_state, old_traps = trap_forwarding
|
|
35
35
|
pid = nil
|
|
36
36
|
streams = []
|
|
@@ -367,8 +367,8 @@ module Samagotchi
|
|
|
367
367
|
prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
|
|
368
368
|
before = installer.conflicts.to_h { |key, info| [key, Samagotchi::MemoryBundle::Merger.digest(info[:current])] }
|
|
369
369
|
@stdout.puts "Launching an interactive session to merge them… (/exit when done)"
|
|
370
|
-
require "samagotchi/terminal_ui"
|
|
371
|
-
Samagotchi::TerminalUI.
|
|
370
|
+
require "samagotchi/terminal_ui/attach_launcher"
|
|
371
|
+
Samagotchi::TerminalUI::AttachLauncher.run(prompt: prompt)
|
|
372
372
|
settle_conflicts(installer.conflicts, before, bundle_name)
|
|
373
373
|
ensure
|
|
374
374
|
# An owned source (git/zip/tar) was kept for the incoming files above;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: decision-card
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "In a session with objectives, a long answer ending in a question gets a card: a short summary and the question, answered in the composer"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
10
|
-
# ctx.
|
|
11
|
-
requires_chi: ">= 0.
|
|
9
|
+
sha256: sha256:b85fd2162e41d1ea059ff6ca89ce6bb543e4f283d18337e9d4ac6936477d3770
|
|
10
|
+
# ctx.last_answer is new after 0.153.0 (ctx.objectives, ctx.turn_running? after 0.148.0)
|
|
11
|
+
requires_chi: ">= 0.154.0"
|
|
12
12
|
# config.yml bundles: decision-card: (another key warns; an older chi ignores this)
|
|
13
13
|
settings: [min_chars, tail_chars, model, timeout, max_tokens]
|
|
@@ -135,7 +135,7 @@ class Plugin
|
|
|
135
135
|
return SHORT_BUSY if ctx.turn_running?
|
|
136
136
|
|
|
137
137
|
messages = ctx.messages
|
|
138
|
-
answer = last_answer
|
|
138
|
+
answer = ctx.last_answer or return "no answer to shorten yet"
|
|
139
139
|
|
|
140
140
|
id = ctx.card(title: SHORT_TITLE, body: "…")
|
|
141
141
|
@mutex.synchronize { @shorts[id] = ctx }
|
|
@@ -158,14 +158,6 @@ class Plugin
|
|
|
158
158
|
ctx.card(id: id, title: SHORT_TITLE, body: body)
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
# @return [String, nil] the newest non-empty answer's text
|
|
162
|
-
def last_answer(messages)
|
|
163
|
-
found = Array(messages).reverse_each.find do |message|
|
|
164
|
-
%w[model assistant].include?(field(message, :role).to_s) && !field(message, :content).to_s.strip.empty?
|
|
165
|
-
end
|
|
166
|
-
found && field(found, :content).to_s
|
|
167
|
-
end
|
|
168
|
-
|
|
169
161
|
def turn_ended(event, ctx)
|
|
170
162
|
return unless event[:status].to_s == "completed"
|
|
171
163
|
# A participant's question is for the participant, on the owner's model; a
|
|
@@ -18,7 +18,7 @@ Single registry `Samagotchi::Config` (`Config::ENTRIES` in `lib/samagotchi/confi
|
|
|
18
18
|
|
|
19
19
|
Sections forbid `_`/`-` (`SECTION_RE` `/\A[a-z0-9]+\z/`), since an env or CLI name is derived from them; a config-only section (`system_one`: `expose: [:config]`, no derived name) may carry `_`. Leaves keep `snake_case` in YAML (`base_url`) and become kebab in CLI (`base-url`) via registry derivation — no generic string split, registry lookup avoids flat vs nested collision.
|
|
20
20
|
|
|
21
|
-
**Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.
|
|
21
|
+
**Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds`, `memory.index_warn_tokens/entry_warn_tokens/read_max_chars`, `default.max_tokens`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
|
|
22
22
|
|
|
23
23
|
Example `config.yml` (new nested form, preferred):
|
|
24
24
|
|
|
@@ -40,10 +40,9 @@ recap:
|
|
|
40
40
|
session:
|
|
41
41
|
retention_days: 14
|
|
42
42
|
max_count: 500
|
|
43
|
-
keep_status: "" # the default: no status protects a session from pruning; a live worker or
|
|
43
|
+
keep_status: "" # the default: no status protects a session from pruning; a live worker or a one-turn run always does
|
|
44
44
|
sweep_interval_hours: 24
|
|
45
45
|
idle_exit_minutes: 30 # a background worker nobody uses exits; 0 = never
|
|
46
|
-
shared: true # the default: plain `chi` runs its session in a background worker and attaches (as `chi --shared`); false keeps the in-process REPL (env SAMAGOTCHI_SESSION_SHARED; no CLI flag, `--no-shared` opts out per run)
|
|
47
46
|
keep_empty: false # the default: a session nothing happened in (no prompt, default model, no note/image) is deleted when left; true keeps it (env SAMAGOTCHI_SESSION_KEEP_EMPTY)
|
|
48
47
|
max_children: 4 # running child sessions one session may have delegated at a time (the delegate tool; env SAMAGOTCHI_SESSION_MAX_CHILDREN)
|
|
49
48
|
log:
|
|
@@ -77,7 +76,7 @@ guardrails:
|
|
|
77
76
|
disable: [guardrails:git-rebase]
|
|
78
77
|
```
|
|
79
78
|
|
|
80
|
-
- `bundles:` map of installed bundle name → its settings (one Hash, string keys, handed to the bundle's hook/plugin `initialize(settings)`). Read when a session's Engine starts: after a change restart the session's worker (`chi sessions stop <id>`)
|
|
79
|
+
- `bundles:` map of installed bundle name → its settings (one Hash, string keys, handed to the bundle's hook/plugin `initialize(settings)`). Read when a session's Engine starts: after a change restart the session's worker (`chi sessions stop <id>`). Unknown keys are ignored by the bundle, not validated. `chi bundle list` names the bundles; each bundle's keys are in `docs/bundles.md` / `docs/guardrails.md`.
|
|
81
80
|
|
|
82
81
|
```yaml
|
|
83
82
|
bundles:
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.154.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
8
|
+
self_map.md: sha256:709bd52710bf6c50334a89a10742ea1554c5985cc2d7c4e7571e526cc4479526
|
|
9
|
+
config_modification_protocol.md: sha256:f3f6545c93d59b624a2bf1766e26059520ffc7b236470dd9f1b51ab77312376d
|
|
10
10
|
memory_guide.md: sha256:8b7727b939b9c04486ca59859561023dcc319a73e0f5b47b3fcf5741edaa69e2
|
|
11
11
|
delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
`docs/bundle-authoring.md` in my source dir first.
|
|
68
68
|
|
|
69
69
|
## Where things live (relative to the source dir)
|
|
70
|
-
- `docs/`: `cli.md` (flags, subcommands,
|
|
70
|
+
- `docs/`: `cli.md` (flags, subcommands, the terminal's commands), `configuration.md` (config.yml keys,
|
|
71
71
|
hosts, the server api), `hooks.md` (hook events table), `plugins.md` (plugin API), `bundles.md` (each shipped bundle: btw, mcp, cost-cap…),
|
|
72
72
|
`guardrails.md`, `memory.md`, `sessions.md`. `README.md` is only the quickstart.
|
|
73
73
|
- `lib/samagotchi/tools/<tool>.rb`: each tool's real limits and defaults (e.g. `execute.rb`,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-search
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: "web_search: the model searches the web through your SearXNG (its JSON API) and gets titles, URLs and snippets to read with web_fetch"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:ff79881a370a10dfd1762b5fecb02833dd30c8c5e1f8578e3f1f1a214d6f78a5
|
|
10
|
+
# settings: is new in chi 0.143.0 (ctx.http in 0.80.0)
|
|
11
|
+
requires_chi: ">= 0.143.0"
|
|
12
|
+
# config.yml bundles: web-search: (another key warns; an older chi ignores this)
|
|
13
|
+
settings: {url: string, max_results: number, timeout: number, language: string, safesearch: number, categories: string, engines: string}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The web-search bundle (docs/bundles.md, The web-search bundle): one tool,
|
|
4
|
+
# web_search, that asks a SearXNG instance through its JSON API and returns
|
|
5
|
+
# a short list of results (title, URL, snippet). The model reads the ones it
|
|
6
|
+
# wants with web_fetch. The query goes to that SearXNG and on to the engines
|
|
7
|
+
# it is configured with.
|
|
8
|
+
#
|
|
9
|
+
# Settings (config.yml, bundles: web-search:), read at each call from
|
|
10
|
+
# ctx.settings, never at load:
|
|
11
|
+
# url: http://127.0.0.1:8888 the SearXNG root (http/https with a host)
|
|
12
|
+
# max_results: 8 results listed, 1..20
|
|
13
|
+
# timeout: 10 seconds, ctx.http's per-read timeout
|
|
14
|
+
# language: en optional, sent only when set
|
|
15
|
+
# safesearch: 0 optional, 0/1/2
|
|
16
|
+
# categories: general optional, comma-separated
|
|
17
|
+
# engines: duckduckgo,brave optional, comma-separated (SearXNG drops
|
|
18
|
+
# names it doesn't know)
|
|
19
|
+
#
|
|
20
|
+
# SearXNG needs `json` in search.formats (else it answers 403) and, for a
|
|
21
|
+
# local instance, server.limiter: false (else it may answer 429).
|
|
22
|
+
require "json"
|
|
23
|
+
require "uri"
|
|
24
|
+
|
|
25
|
+
class Plugin
|
|
26
|
+
DEFAULT_URL = "http://127.0.0.1:8888"
|
|
27
|
+
DEFAULT_MAX = 8
|
|
28
|
+
MAX_RESULTS = 20
|
|
29
|
+
DEFAULT_TIMEOUT = 10
|
|
30
|
+
SNIPPET_CHARS = 300
|
|
31
|
+
TIME_RANGES = %w[day week month year].freeze
|
|
32
|
+
# Optional settings sent as query parameters, in this order, only when set.
|
|
33
|
+
OPTIONAL = %w[language safesearch categories engines].freeze
|
|
34
|
+
|
|
35
|
+
DESCRIPTION = "Search the web (through the user's SearXNG) and return a numbered list of results: title, URL and " \
|
|
36
|
+
"a short snippet. Snippets are partial and may be stale: when the details matter, read a result's " \
|
|
37
|
+
"URL with web_fetch before answering instead of answering from snippets alone. Use page for more " \
|
|
38
|
+
"results and time_range for recent ones."
|
|
39
|
+
|
|
40
|
+
# A setting that can't be used: the tool's answer, before any request.
|
|
41
|
+
class SettingError < StandardError; end
|
|
42
|
+
|
|
43
|
+
def initialize(_settings); end
|
|
44
|
+
|
|
45
|
+
def register(chi)
|
|
46
|
+
chi.tool "web_search", DESCRIPTION,
|
|
47
|
+
params: {
|
|
48
|
+
query: { type: "string", description: "The search terms", required: true },
|
|
49
|
+
time_range: { type: "string", enum: TIME_RANGES, description: "Only results from the last day, week, month or year" },
|
|
50
|
+
page: { type: "integer", description: "The results page, 1 for the first (the default)" }
|
|
51
|
+
},
|
|
52
|
+
label: "searching", preview: ->(args) { args["query"].to_s } do |args, ctx|
|
|
53
|
+
search(args, ctx)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def search(args, ctx)
|
|
60
|
+
query = args["query"].to_s.strip
|
|
61
|
+
return "Error: query is required" if query.empty?
|
|
62
|
+
|
|
63
|
+
settings = ctx.settings
|
|
64
|
+
root = root_url(settings["url"])
|
|
65
|
+
page = page_of(args["page"])
|
|
66
|
+
res = ctx.http.get("#{root}/search?#{URI.encode_www_form(pairs(query, page, args["time_range"], settings))}",
|
|
67
|
+
timeout: timeout_of(settings["timeout"]))
|
|
68
|
+
answer(res, query, page, max_of(settings["max_results"]))
|
|
69
|
+
rescue SettingError => e
|
|
70
|
+
"Error: #{e.message}"
|
|
71
|
+
rescue Samagotchi::Plugin::HttpCancelled
|
|
72
|
+
"Error: search cancelled"
|
|
73
|
+
rescue Samagotchi::Plugin::HttpError => e
|
|
74
|
+
"Error: SearXNG is not reachable: #{e.message}. Is it running? (bundles: web-search: url)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def answer(res, query, page, max)
|
|
78
|
+
return status_error(res.status) unless res.ok?
|
|
79
|
+
|
|
80
|
+
data = begin
|
|
81
|
+
JSON.parse(res.body.scrub)
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
return "Error: SearXNG's answer isn't JSON (is url the SearXNG root?)" unless data.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
listing(data, query, page, max)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def status_error(status)
|
|
91
|
+
case status
|
|
92
|
+
when 403 then "Error: SearXNG refused the JSON format (403): add json to search.formats in its settings.yml"
|
|
93
|
+
when 429 then "Error: SearXNG rate-limited the request (429): turn its limiter off for a local instance (server.limiter: false)"
|
|
94
|
+
else "Error: SearXNG answered #{status}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def listing(data, query, page, max)
|
|
99
|
+
results = Array(data["results"]).select { |entry| entry.is_a?(Hash) && !squash(entry["url"]).empty? }.first(max)
|
|
100
|
+
if results.empty?
|
|
101
|
+
failed = Array(data["unresponsive_engines"]).map { |pair| squash(Array(pair).first) }.reject(&:empty?)
|
|
102
|
+
return [%(No results for "#{query}".), (failed.empty? ? nil : "Engines that failed: #{failed.join(", ")}")].compact.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lines = [%(SearXNG results for "#{query}" (page #{page}):)]
|
|
106
|
+
if (first = first_answer(data["answers"]))
|
|
107
|
+
lines << "Answer: #{first}"
|
|
108
|
+
end
|
|
109
|
+
results.each_with_index { |entry, index| lines.concat(result_lines(entry, index + 1)) }
|
|
110
|
+
suggestions = Array(data["suggestions"]).map { |text| squash(text) }.reject(&:empty?)
|
|
111
|
+
lines << "Suggestions: #{suggestions.join(", ")}" unless suggestions.empty?
|
|
112
|
+
lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def result_lines(entry, number)
|
|
116
|
+
url = squash(entry["url"])
|
|
117
|
+
title = squash(entry["title"])
|
|
118
|
+
snippet = cut(squash(entry["content"]))
|
|
119
|
+
["#{number}. #{title.empty? ? url : title}", " #{url}", (snippet.empty? ? nil : " #{snippet}")].compact
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# answers: Strings (older SearXNG) or Hashes with the text under "answer".
|
|
123
|
+
def first_answer(answers)
|
|
124
|
+
Array(answers).each do |item|
|
|
125
|
+
text = cut(squash(item.is_a?(Hash) ? item["answer"] : item))
|
|
126
|
+
return text unless text.empty?
|
|
127
|
+
end
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pairs(query, page, time_range, settings)
|
|
132
|
+
list = [["q", query], %w[format json], ["pageno", page.to_s]]
|
|
133
|
+
list << ["time_range", time_range.to_s] if TIME_RANGES.include?(time_range.to_s)
|
|
134
|
+
OPTIONAL.each do |key|
|
|
135
|
+
value = settings[key]
|
|
136
|
+
list << [key, value.to_s] unless value.nil? || value.to_s.strip.empty?
|
|
137
|
+
end
|
|
138
|
+
list
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def root_url(value)
|
|
142
|
+
url = (value.nil? ? DEFAULT_URL : value.to_s).strip
|
|
143
|
+
uri = URI.parse(url)
|
|
144
|
+
raise SettingError, "bundles: web-search: url must be an http(s) URL" unless %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
145
|
+
|
|
146
|
+
url.chomp("/")
|
|
147
|
+
rescue URI::InvalidURIError
|
|
148
|
+
raise SettingError, "bundles: web-search: url must be an http(s) URL"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def page_of(value)
|
|
152
|
+
page = begin
|
|
153
|
+
Integer(value)
|
|
154
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
155
|
+
1
|
|
156
|
+
end
|
|
157
|
+
[page, 1].max
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def timeout_of(value) = value.is_a?(Numeric) && value.positive? ? value : DEFAULT_TIMEOUT
|
|
161
|
+
|
|
162
|
+
def max_of(value)
|
|
163
|
+
max = begin
|
|
164
|
+
Integer(value)
|
|
165
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
166
|
+
DEFAULT_MAX
|
|
167
|
+
end
|
|
168
|
+
max.clamp(1, MAX_RESULTS)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def squash(value) = value.to_s.scrub.gsub(/\s+/, " ").strip
|
|
172
|
+
|
|
173
|
+
def cut(text) = text.length > SNIPPET_CHARS ? "#{text[0, SNIPPET_CHARS - 1]}…" : text
|
|
174
|
+
end
|