samagotchi 0.133.0 → 0.135.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 +116 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +50 -34
- data/docs/configuration.md +9 -6
- data/docs/guardrails.md +146 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +7 -6
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/internals/web-frontend.md +262 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -2
- data/docs/testing.md +34 -4
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +82 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
- data/lib/samagotchi/guardrails/pair_command.rb +828 -86
- data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
- data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +70 -16
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +462 -338
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +9 -4
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +27 -0
- data/lib/samagotchi/web/public/index.html +82 -37
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +17 -9
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +16 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -80,7 +80,8 @@ module Samagotchi
|
|
|
80
80
|
# chi home doctor's row when session.project_env is mise and no mise is found.
|
|
81
81
|
NO_MISE = "project env: no mise found (home.json's, PATH, /opt/homebrew/bin, /usr/local/bin, ~/.local/bin): " \
|
|
82
82
|
"tools run in the env of whoever starts the worker; install mise, or set session.project_env: off"
|
|
83
|
-
# How much of the debug log's end chi home doctor reads
|
|
83
|
+
# How much of the debug log's end chi home doctor reads (#last_warning,
|
|
84
|
+
# HomeDoctor's boot row).
|
|
84
85
|
LOG_TAIL_BYTES = 4 * 1024 * 1024
|
|
85
86
|
|
|
86
87
|
# A worker's `project_env` warning from the debug log (#last_warning).
|
|
@@ -327,7 +328,7 @@ module Samagotchi
|
|
|
327
328
|
|
|
328
329
|
want = Array(dirs).map { |dir| ChiHome.real(dir) }
|
|
329
330
|
newest = {}
|
|
330
|
-
each_tail_record(log_path) do |record|
|
|
331
|
+
LogLine.each_tail_record(log_path, bytes: LOG_TAIL_BYTES) do |record|
|
|
331
332
|
next unless record.tag == "worker" && %w[project_env project_env_applied].include?(record.event)
|
|
332
333
|
|
|
333
334
|
dir = record.fields["dir"]
|
|
@@ -342,15 +343,6 @@ module Samagotchi
|
|
|
342
343
|
nil
|
|
343
344
|
end
|
|
344
345
|
|
|
345
|
-
def each_tail_record(log_path, &)
|
|
346
|
-
File.open(log_path, "rb") do |io|
|
|
347
|
-
size = io.size
|
|
348
|
-
io.seek([size - LOG_TAIL_BYTES, 0].max)
|
|
349
|
-
io.gets if size > LOG_TAIL_BYTES # a torn first line
|
|
350
|
-
LogLine.each_record(io, &)
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
|
|
354
346
|
def configured_mode
|
|
355
347
|
Config.get("session.project_env").to_s
|
|
356
348
|
rescue StandardError
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verify_anchor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The earlier turn a Revise turn rewrites the answer of, carried only by
|
|
7
|
+
# a Revise turn (TurnAnchors). Its stamp on the turn's opening message
|
|
8
|
+
# is display metadata, never in the prompt (Engine#revision_target).
|
|
9
|
+
ReviseAnchor = Data.define(:turn_id) do
|
|
10
|
+
def self.from(value)
|
|
11
|
+
return value if value.is_a?(self)
|
|
12
|
+
return nil unless value.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
fields = value.to_h { |key, entry| [key.to_s, entry] }
|
|
15
|
+
return nil unless fields.keys == %w[turn_id]
|
|
16
|
+
|
|
17
|
+
turn_id = fields["turn_id"]
|
|
18
|
+
return nil unless turn_id.is_a?(String) && VerifyAnchor::UUID.match?(turn_id)
|
|
19
|
+
|
|
20
|
+
new(turn_id:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h = { turn_id: }
|
|
24
|
+
|
|
25
|
+
def to_file = { "turn_id" => turn_id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
ReviseAnchor::FEATURE = "revise"
|
|
29
|
+
ReviseAnchor::UNAVAILABLE = {
|
|
30
|
+
"error" => "revise_unavailable",
|
|
31
|
+
"detail" => "this session's worker predates Revise turns; restart this session to use Revise"
|
|
32
|
+
}.freeze
|
|
33
|
+
end
|
|
@@ -25,6 +25,7 @@ require_relative "desktop"
|
|
|
25
25
|
require_relative "live_versions"
|
|
26
26
|
require_relative "web/lan"
|
|
27
27
|
require_relative "chi_home"
|
|
28
|
+
require_relative "home_notice"
|
|
28
29
|
require_relative "project_env"
|
|
29
30
|
|
|
30
31
|
module Samagotchi
|
|
@@ -429,7 +430,8 @@ module Samagotchi
|
|
|
429
430
|
|
|
430
431
|
label = "#{home.ruby} (chi #{home.newest_version})"
|
|
431
432
|
return "#{label}; this chi is a source checkout" unless InstalledGem.spec
|
|
432
|
-
return label if home
|
|
433
|
+
return label if HomeNotice.home?(home)
|
|
434
|
+
return "#{label}; this chi is #{VERSION}" if home.running_here?
|
|
433
435
|
|
|
434
436
|
"#{label}; this chi runs on #{RbConfig.ruby}"
|
|
435
437
|
rescue ChiHome::Invalid => e
|
|
@@ -12,8 +12,10 @@ require_relative "steer"
|
|
|
12
12
|
require_relative "turn_origin"
|
|
13
13
|
|
|
14
14
|
module Samagotchi
|
|
15
|
-
# Loaded on first use (
|
|
16
|
-
# load budget
|
|
15
|
+
# Loaded on first use (any turn's input, a Verify or a Revise turn's
|
|
16
|
+
# anchor): keeps the bundle test kit's load budget
|
|
17
|
+
# (spec/samagotchi/testing/sandbox_spec.rb).
|
|
18
|
+
autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
|
|
17
19
|
autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
|
|
18
20
|
|
|
19
21
|
# A session directory's file inbox and outbox (see SessionManager for the
|
|
@@ -54,10 +56,12 @@ module Samagotchi
|
|
|
54
56
|
# @param author [String, nil] who wrote the message when it isn't the
|
|
55
57
|
# session's owner (Web::Principal#author); nil, the owner's, is never
|
|
56
58
|
# written, so an owner's file is as it always was
|
|
59
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
60
|
+
# turn's draft (TurnAnchors#to_file's keys; none: none written)
|
|
57
61
|
# @param now [Time] the time the file is named by (a spec's clock)
|
|
58
62
|
# @return [String, false] the input file's path, or false
|
|
59
63
|
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
|
|
60
|
-
delivery: nil, author: nil,
|
|
64
|
+
delivery: nil, author: nil, anchors: nil, now: Time.now)
|
|
61
65
|
images = Array(images)
|
|
62
66
|
input_dir = File.join(session_dir, INPUT_DIR)
|
|
63
67
|
FileUtils.mkdir_p(input_dir)
|
|
@@ -67,7 +71,8 @@ module Samagotchi
|
|
|
67
71
|
"no_interrupt" => (no_interrupt ? true : nil),
|
|
68
72
|
"delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
|
|
69
73
|
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }),
|
|
70
|
-
"author" => author
|
|
74
|
+
"author" => author }.compact
|
|
75
|
+
record.merge!(TurnAnchors.from(anchors).to_file) if anchors
|
|
71
76
|
AtomicFile.write(path, JSON.generate(record))
|
|
72
77
|
path
|
|
73
78
|
rescue StandardError
|
|
@@ -231,10 +236,12 @@ module Samagotchi
|
|
|
231
236
|
# no sender), whether its turn runs with the raised iteration limit
|
|
232
237
|
# (+no_interrupt+, --no-interrupt), its +images+ ({file:, name:} refs),
|
|
233
238
|
# and its +delivery+ ("queue" or nil; a file without the key is one an
|
|
234
|
-
# older chi wrote, so nil)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
239
|
+
# older chi wrote, so nil), and its +anchors+ (TurnAnchors, nil when it
|
|
240
|
+
# names none: TurnAnchors loads on first use, not with UNREADABLE_INPUT).
|
|
241
|
+
Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery, :anchors) do
|
|
242
|
+
def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil, anchors: nil)
|
|
243
|
+
anchors &&= TurnAnchors.from(anchors)
|
|
244
|
+
super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:, anchors: anchors && anchors.empty? ? nil : anchors)
|
|
238
245
|
end
|
|
239
246
|
end
|
|
240
247
|
# A file that doesn't parse, or isn't a JSON object: no prompt.
|
|
@@ -250,7 +257,7 @@ module Samagotchi
|
|
|
250
257
|
origin = TurnOrigin.from(data.slice("client_id", "enqueued_id", "author"))
|
|
251
258
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
252
259
|
Input.new(prompt: data["prompt"].to_s, origin: origin, no_interrupt: data["no_interrupt"] == true,
|
|
253
|
-
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil,
|
|
260
|
+
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil, anchors: data)
|
|
254
261
|
rescue JSON::ParserError
|
|
255
262
|
UNREADABLE_INPUT
|
|
256
263
|
end
|
|
@@ -282,11 +289,12 @@ module Samagotchi
|
|
|
282
289
|
true
|
|
283
290
|
end
|
|
284
291
|
|
|
285
|
-
# A parsed input file's #waits_for_turn_end?. A Verify
|
|
286
|
-
# merged into a running turn as a steer, its anchor would be
|
|
292
|
+
# A parsed input file's #waits_for_turn_end?. A Verify or a Revise turn
|
|
293
|
+
# waits too: merged into a running turn as a steer, its anchor would be
|
|
294
|
+
# lost.
|
|
287
295
|
def self.turn_end_data?(data)
|
|
288
296
|
Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"]) ||
|
|
289
|
-
|
|
297
|
+
TurnAnchors.named_in?(data)
|
|
290
298
|
end
|
|
291
299
|
|
|
292
300
|
# Whether a message waits that a running turn's next boundary merges and
|
|
@@ -1368,10 +1368,11 @@ module Samagotchi
|
|
|
1368
1368
|
# session's images/
|
|
1369
1369
|
# @return [String, false] the input file's path, or false.
|
|
1370
1370
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1371
|
-
images: [], delivery: nil, author: nil,
|
|
1371
|
+
images: [], delivery: nil, author: nil, anchors: nil)
|
|
1372
1372
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1373
1373
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1374
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1374
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1375
|
+
anchors: anchors)
|
|
1375
1376
|
end
|
|
1376
1377
|
|
|
1377
1378
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -1440,6 +1441,9 @@ module Samagotchi
|
|
|
1440
1441
|
# @param pending_limit [Integer, nil] with +author+: how many of their
|
|
1441
1442
|
# turns may be running or waiting (the Bridge counts them; with no
|
|
1442
1443
|
# worker, their waiting files): :refused 429 too_many_turns past it
|
|
1444
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
1445
|
+
# turn's draft: each refused (409, its UNAVAILABLE) by a worker without
|
|
1446
|
+
# its feature
|
|
1443
1447
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1444
1448
|
# stand-in (the web's specs); #session_owner is optional
|
|
1445
1449
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1453,8 +1457,8 @@ module Samagotchi
|
|
|
1453
1457
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1454
1458
|
# @raise [ArgumentError] no such session
|
|
1455
1459
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
|
|
1456
|
-
author: nil, pending_limit: nil,
|
|
1457
|
-
|
|
1460
|
+
author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
|
|
1461
|
+
anchors = TurnAnchors.from(anchors)
|
|
1458
1462
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1459
1463
|
bridge ||= lambda do
|
|
1460
1464
|
link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
|
|
@@ -1462,7 +1466,9 @@ module Samagotchi
|
|
|
1462
1466
|
end
|
|
1463
1467
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
1464
1468
|
if (client = bridge.call)
|
|
1465
|
-
|
|
1469
|
+
if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
|
|
1470
|
+
return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
|
|
1471
|
+
end
|
|
1466
1472
|
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1467
1473
|
# field or refuses it; either way a file would steer the running turn.
|
|
1468
1474
|
return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
|
|
@@ -1477,7 +1483,7 @@ module Samagotchi
|
|
|
1477
1483
|
options[:no_interrupt] = true if no_interrupt
|
|
1478
1484
|
options[:author] = author if author
|
|
1479
1485
|
options[:pending_limit] = pending_limit if author && pending_limit
|
|
1480
|
-
options[:
|
|
1486
|
+
options[:anchors] = anchors unless anchors.empty?
|
|
1481
1487
|
reply = client.post_turn(**options)
|
|
1482
1488
|
ack = reply.json
|
|
1483
1489
|
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1515,7 +1521,7 @@ module Samagotchi
|
|
|
1515
1521
|
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1516
1522
|
input[:no_interrupt] = true if no_interrupt
|
|
1517
1523
|
input[:author] = author if author
|
|
1518
|
-
input[:
|
|
1524
|
+
input[:anchors] = anchors unless anchors.empty?
|
|
1519
1525
|
path = participant_queue_lock(session_dir, author && pending_limit) do
|
|
1520
1526
|
# No worker: the participant's waiting files are all their turns,
|
|
1521
1527
|
# counted and written under one lock (the Bridge counts under its).
|
|
@@ -1581,10 +1587,6 @@ module Samagotchi
|
|
|
1581
1587
|
DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
|
|
1582
1588
|
end
|
|
1583
1589
|
|
|
1584
|
-
private_class_method def self.verify_unavailable
|
|
1585
|
-
DeliverResult.new(status: :refused, code: 409, ack: VerifyAnchor::UNAVAILABLE)
|
|
1586
|
-
end
|
|
1587
|
-
|
|
1588
1590
|
private_class_method def self.turn_timeout
|
|
1589
1591
|
DeliverResult.new(status: :timeout, via: :bridge,
|
|
1590
1592
|
ack: { "error" => "worker_timeout",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
|
+
require_relative "../guardrails/approval"
|
|
5
6
|
require_relative "question"
|
|
6
7
|
require_relative "similar"
|
|
7
8
|
|
|
@@ -35,29 +36,31 @@ module Samagotchi
|
|
|
35
36
|
Question.new(id: ID, type: "noul", instructions: INSTRUCTIONS)
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
# @param approval [Hash] payload[:approval]:
|
|
39
|
+
# @param approval [Guardrails::ApprovalFacts, Hash] payload[:approval]:
|
|
40
|
+
# the ask the user is shown (a saved card's hash too)
|
|
39
41
|
# @param session_title [String, nil] the session's title, when it has one
|
|
40
42
|
# @param entries [Array<Hash>] SystemOne::Log#entries: the judgments the
|
|
41
43
|
# user has answered so far, read once per ask and shared with the
|
|
42
44
|
# record gate (GuardrailWiring#system_one_judge)
|
|
43
45
|
# @return [Hash] the state, with the fields the ask didn't give left out
|
|
44
46
|
def self.state(approval, session_title: nil, entries: nil)
|
|
47
|
+
approval = Guardrails::ApprovalFacts.from_h(approval)
|
|
45
48
|
{
|
|
46
|
-
"rule" => approval
|
|
47
|
-
"source" => approval
|
|
48
|
-
"reason" => approval
|
|
49
|
-
"tool" => approval
|
|
50
|
-
"command" => cut(approval
|
|
51
|
-
"paths" => Array(approval
|
|
52
|
-
"change" => change_text(approval
|
|
53
|
-
"cwd" => approval
|
|
54
|
-
"repo_name" => approval
|
|
55
|
-
"branch" => approval
|
|
49
|
+
"rule" => approval.rule,
|
|
50
|
+
"source" => approval.source,
|
|
51
|
+
"reason" => approval.reason,
|
|
52
|
+
"tool" => approval.tool,
|
|
53
|
+
"command" => cut(approval.command),
|
|
54
|
+
"paths" => Array(approval.paths).map { |path| cut(path) },
|
|
55
|
+
"change" => change_text(approval.preview),
|
|
56
|
+
"cwd" => approval.cwd,
|
|
57
|
+
"repo_name" => approval.repo_name,
|
|
58
|
+
"branch" => approval.branch,
|
|
56
59
|
"session" => session_title,
|
|
57
60
|
# Which memory entry the ask is about, when it is a memory ask.
|
|
58
|
-
"memory" => memory_facts(approval
|
|
61
|
+
"memory" => memory_facts(approval.memory),
|
|
59
62
|
# The asks this one looks like, and what the user did about them.
|
|
60
|
-
"examples" => Similar.select(entries: entries, rule: approval
|
|
63
|
+
"examples" => Similar.select(entries: entries, rule: approval.rule, repo_name: approval.repo_name,
|
|
61
64
|
call_text: call_text(approval))
|
|
62
65
|
}.reject do |_key, value|
|
|
63
66
|
value.nil? || (value.is_a?(String) && value.strip.empty?) ||
|
|
@@ -67,14 +70,16 @@ module Samagotchi
|
|
|
67
70
|
|
|
68
71
|
# What the call is, whole: the command, the paths, or a plugin tool's
|
|
69
72
|
# arguments.
|
|
73
|
+
# @param approval [Guardrails::ApprovalFacts, Hash] as built or saved
|
|
70
74
|
def self.call_text(approval)
|
|
71
|
-
|
|
75
|
+
approval = Guardrails::ApprovalFacts.from_h(approval)
|
|
76
|
+
command = approval.command
|
|
72
77
|
return command.to_s if command && !command.to_s.empty?
|
|
73
78
|
|
|
74
|
-
paths = Array(approval
|
|
79
|
+
paths = Array(approval.paths)
|
|
75
80
|
return paths.join("\n") unless paths.empty?
|
|
76
81
|
|
|
77
|
-
approval
|
|
82
|
+
approval.args.to_s
|
|
78
83
|
end
|
|
79
84
|
|
|
80
85
|
# The judgment log's two fields for the call: what it was, cut to
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
require_relative "../config"
|
|
4
4
|
require_relative "../memory_paths"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
+
require_relative "../guardrails/approval"
|
|
6
7
|
require_relative "../guardrails/chi_dirs"
|
|
7
8
|
require_relative "../guardrails/path_resolve"
|
|
9
|
+
require_relative "../guardrails/pair_command"
|
|
8
10
|
require_relative "../guardrails/parent_approvals"
|
|
9
11
|
require_relative "../guardrails/shell_lex"
|
|
10
12
|
require_relative "../guardrails/shell_paths"
|
|
@@ -31,7 +33,12 @@ module Samagotchi
|
|
|
31
33
|
# `/`) read while the dir was known. A word the walk can't follow
|
|
32
34
|
# otherwise (`${PWD%/*}`, `$(dirname $PWD)`, `cd -`) may climb out of the
|
|
33
35
|
# memories dir without saying so, so its memories text stays in and the
|
|
34
|
-
# text check refuses it.
|
|
36
|
+
# text check refuses it. So does a script an interpreter or shell runs
|
|
37
|
+
# that climbs by its own calls (SCRIPT_CLIMB: os.pardir, dirname,
|
|
38
|
+
# chdir, Dir.pwd, Path.parent, rindex, a slice from the end), and one
|
|
39
|
+
# chi can't see: a script file it names, or stdin
|
|
40
|
+
# (Guardrails::PairCommand.script_source). A heredoc or phrase that is
|
|
41
|
+
# only written (cat >> note <<EOF) is data whatever it says.
|
|
35
42
|
#
|
|
36
43
|
# Pure: it only reads the paths as this process's environment names them,
|
|
37
44
|
# and answers on the approval's own facts (Guardrails::Approval.payload's
|
|
@@ -58,6 +65,21 @@ module Samagotchi
|
|
|
58
65
|
/(?:\A|[\s;&|(])(?:cd|pushd)\s+-(?=[\s;&|)]|\z)/
|
|
59
66
|
)
|
|
60
67
|
|
|
68
|
+
# An interpreter's own calls that climb out of a dir or cut a path
|
|
69
|
+
# from its end, as a script body may spell them without `..` or `$`
|
|
70
|
+
# (an accident guard, not a parser: a script can always build a path
|
|
71
|
+
# some other way).
|
|
72
|
+
SCRIPT_CLIMB = Regexp.union(
|
|
73
|
+
/\b(?:pardir|dirname|parents?|chdir|getcwd|cwd|pwd|abspath|realpath|normpath|expand_path|absolute_path|
|
|
74
|
+
resolve|rsplit|rpartition|rindex|rfind|lastIndexOf)\b/x,
|
|
75
|
+
/\b(?:os\.path|File)\.split\b/, /\[[^\]\[]*:\s*-/
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Programs that run a script file (theirs or the dir's) by the words
|
|
79
|
+
# that make them do it ([]: any call): unseen, like a shell's file.
|
|
80
|
+
FILE_RUNNERS = { /\Ag?make\z/ => [], /\Arake\z/ => [], /\A[gmn]?awk\z/ => %w[-f], /\Adeno\z/ => %w[run task],
|
|
81
|
+
/\A(?:npm|yarn|pnpm|bun)\z/ => %w[run run-script test start exec x] }.freeze
|
|
82
|
+
|
|
61
83
|
# What resolving a command's words found: whether one lands in chi's
|
|
62
84
|
# dirs (outside the memories dir, or in its installed bundles), and
|
|
63
85
|
# whether every word resolved or is data (#plain).
|
|
@@ -65,20 +87,21 @@ module Samagotchi
|
|
|
65
87
|
|
|
66
88
|
module_function
|
|
67
89
|
|
|
68
|
-
# @param approval [Hash, nil] the ask's
|
|
69
|
-
# paths, command, cwd)
|
|
90
|
+
# @param approval [Guardrails::ApprovalFacts, Hash, nil] the ask's
|
|
91
|
+
# approval facts (rule, tool, paths, command, cwd), as built or saved
|
|
70
92
|
# @param targets [Guardrails::Targets, nil] the call's targets, for its
|
|
71
93
|
# env: (Targets#shell_envs, #redefines_shell?)
|
|
72
94
|
# @return [Boolean] whether System 1 must not answer this ask
|
|
73
95
|
def refuse?(approval, targets: nil)
|
|
74
|
-
|
|
75
|
-
return
|
|
96
|
+
approval = Guardrails::ApprovalFacts.from_h(approval)
|
|
97
|
+
return false if approval.nil?
|
|
98
|
+
return true if NEVER_AUTO.include?(approval.rule.to_s)
|
|
76
99
|
# An env: that redefines the shell (BASH_ENV, BASH_FUNC_…) runs what
|
|
77
100
|
# no word of the command says.
|
|
78
101
|
return true if targets&.redefines_shell?
|
|
79
102
|
|
|
80
|
-
command =
|
|
81
|
-
words = command.empty? ? nil : words(command, cwd:
|
|
103
|
+
command = approval.command.to_s
|
|
104
|
+
words = command.empty? ? nil : words(command, cwd: approval.cwd, targets: targets)
|
|
82
105
|
return true if words&.chi
|
|
83
106
|
return true if chi_paths?(approval)
|
|
84
107
|
# The test that makes an ask user_only (ParentApprovals#chi_files?:
|
|
@@ -95,7 +118,7 @@ module Samagotchi
|
|
|
95
118
|
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
96
119
|
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
97
120
|
dirs = chi_dirs
|
|
98
|
-
Array(
|
|
121
|
+
Array(approval.paths).map(&:to_s).reject(&:empty?).any? do |path|
|
|
99
122
|
Guardrails::PathResolve.reals(path).any? do |real|
|
|
100
123
|
dirs.any? { |dir| Guardrails::PathResolve.within?(real, dir) } && !memory?(real, memories, bundles)
|
|
101
124
|
end
|
|
@@ -108,12 +131,12 @@ module Samagotchi
|
|
|
108
131
|
def beyond_memories(approval, exempt:)
|
|
109
132
|
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
110
133
|
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
111
|
-
paths = Array(
|
|
134
|
+
paths = Array(approval.paths).map(&:to_s).reject do |path|
|
|
112
135
|
Guardrails::PathResolve.reals(path).all? { |real| memory?(real, memories, bundles) }
|
|
113
136
|
end
|
|
114
|
-
command =
|
|
137
|
+
command = approval.command.to_s
|
|
115
138
|
command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
|
|
116
|
-
|
|
139
|
+
approval.with(paths: paths, command: command)
|
|
117
140
|
end
|
|
118
141
|
|
|
119
142
|
# Whether the command's memories text may be taken out: no command,
|
|
@@ -133,14 +156,18 @@ module Samagotchi
|
|
|
133
156
|
tokens.none? { |_, value| value.include?(Guardrails::ShellLex::SUBST) }
|
|
134
157
|
chi = false
|
|
135
158
|
commands = Guardrails::ShellLex.simple_commands(tokens)
|
|
159
|
+
pipes = Guardrails::ShellLex.piped_flags(tokens)
|
|
136
160
|
envs = targets ? targets.shell_envs : [[Dir.home, ENV]]
|
|
137
161
|
envs.each do |home, env|
|
|
162
|
+
piped = pipes.dup
|
|
138
163
|
walk = Guardrails::ShellPaths::Walk.new(cwd.to_s.empty? ? Dir.pwd : cwd.to_s, home, env)
|
|
139
164
|
commands.each do |simple|
|
|
140
165
|
# A cd the walk couldn't follow: no later word is data.
|
|
141
166
|
known = !simple.is_a?(Array) || !walk.paths(".").empty?
|
|
167
|
+
runs = simple.is_a?(Array) && runs_script?(simple)
|
|
168
|
+
plain &&= !unseen_script?(simple, piped: simple.is_a?(Array) && piped.shift)
|
|
142
169
|
walk.step(simple).each do |word, path|
|
|
143
|
-
next plain &&= known && data?(word) unless path
|
|
170
|
+
next plain &&= known && data?(word) && !(runs && climbs?(word)) unless path
|
|
144
171
|
|
|
145
172
|
chi ||= Guardrails::PathResolve.reals(path).any? do |real|
|
|
146
173
|
dirs.any? { |dir| Guardrails::PathResolve.under?(real, dir) } && !memory?(real, memories, bundles)
|
|
@@ -165,6 +192,37 @@ module Samagotchi
|
|
|
165
192
|
word.match?(Guardrails::ShellPaths::GLOB) && !word.include?("/")
|
|
166
193
|
end
|
|
167
194
|
|
|
195
|
+
# Whether a simple command hands a script to a shell or interpreter
|
|
196
|
+
# (one anywhere in it, find -execdir's too) or has a code option.
|
|
197
|
+
def runs_script?(simple)
|
|
198
|
+
simple.any? do |word|
|
|
199
|
+
Guardrails::PairCommand.runner?(word) || Guardrails::PairCommand::CODE_OPTIONS.include?(word)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Whether a simple command runs a script chi can't see: its shell or
|
|
204
|
+
# interpreter (any version's name: python3.12, pypy3, Rscript, tclsh)
|
|
205
|
+
# runs a file or stdin, a pipe into it included whatever its options
|
|
206
|
+
# (Guardrails::PairCommand.script_source), or another program runs a
|
|
207
|
+
# file of the dir it is in (FILE_RUNNERS: make, awk -f, deno run,
|
|
208
|
+
# npm run).
|
|
209
|
+
def unseen_script?(simple, piped: false)
|
|
210
|
+
return false unless simple.is_a?(Array)
|
|
211
|
+
return true if %i[file stdin].include?(Guardrails::PairCommand.script_source(simple, piped: piped))
|
|
212
|
+
|
|
213
|
+
simple.each_with_index.any? do |word, i|
|
|
214
|
+
next false if word.include?("\0")
|
|
215
|
+
|
|
216
|
+
verbs = FILE_RUNNERS.find { |pattern, _| Guardrails::ShellLex.program_name(word).match?(pattern) }&.last
|
|
217
|
+
verbs && (verbs.empty? || simple[(i + 1)..].intersect?(verbs))
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Whether a script word, its memories text aside, climbs (SCRIPT_CLIMB).
|
|
222
|
+
def climbs?(word)
|
|
223
|
+
word.match?(Guardrails::ShellPaths::SCRIPT) && word.gsub(memories_text, "").match?(SCRIPT_CLIMB)
|
|
224
|
+
end
|
|
225
|
+
|
|
168
226
|
# chi's own dirs (ParentApprovals.chi_dirs) and its whole state dir
|
|
169
227
|
# (DEFAULT_STATE_TEXT's), resolved (Guardrails::ChiDirs.self_protect).
|
|
170
228
|
def chi_dirs = Guardrails::ChiDirs.self_protect
|
|
@@ -184,10 +242,6 @@ module Samagotchi
|
|
|
184
242
|
word = WORD_END.sub("[", "[^")
|
|
185
243
|
Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{word}*)?")
|
|
186
244
|
end
|
|
187
|
-
|
|
188
|
-
def fetch(hash, key)
|
|
189
|
-
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
190
|
-
end
|
|
191
245
|
end
|
|
192
246
|
end
|
|
193
247
|
end
|
|
@@ -108,6 +108,19 @@ module Samagotchi
|
|
|
108
108
|
value
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# A wall-clock upper bound for "this didn't take the full wait"
|
|
112
|
+
# (expect(elapsed).to be < time_bound(3, below: 5)): +seconds+ scaled,
|
|
113
|
+
# so a slow runner gets the headroom a helper's deadline gets, and kept
|
|
114
|
+
# under +below+, the unscaled deadline (a lib timeout, a hung child's
|
|
115
|
+
# sleep) the bound tells apart from, so the check still fails on the
|
|
116
|
+
# full wait whatever SPEC_TIME_SCALE is. A bound with nothing to tell
|
|
117
|
+
# apart from (a cost: "no quadratic blow-up") takes no +below+.
|
|
118
|
+
# @return [Float]
|
|
119
|
+
def time_bound(seconds, below: nil)
|
|
120
|
+
bound = Waiting.scaled(seconds)
|
|
121
|
+
below ? [bound, below].min : bound
|
|
122
|
+
end
|
|
123
|
+
|
|
111
124
|
# Whether no process +pid+ is left (a killed grandchild is reaped a
|
|
112
125
|
# moment later: wait_for("pid gone") { process_gone?(pid) }). One that
|
|
113
126
|
# isn't ours to signal is still there (ProcessGroup.alive?).
|
|
@@ -155,6 +155,22 @@ module Samagotchi
|
|
|
155
155
|
true
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# On mode's ask gave the turn no suggestion to judge (Engine
|
|
159
|
+
# #settle_toolkit_suggestion: a timeout, no answer): say, as a warn
|
|
160
|
+
# notice, that +session+ runs its default (not once a toolkit was
|
|
161
|
+
# chosen meanwhile). +why+ is fallback_notice's.
|
|
162
|
+
def fell_back(session, why)
|
|
163
|
+
return if session.nil? || session.toolkit_origin != "default"
|
|
164
|
+
|
|
165
|
+
@notify.call(self.class.fallback_notice(Toolkit.of(session.toolkit).name, why), :warn, "toolkit", extra: {})
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# "toolkit: full (no suggestion: System 1 took over 5000 ms) · /toolkit
|
|
169
|
+
# to change"; +why+ follows "no suggestion: ".
|
|
170
|
+
def self.fallback_notice(name, why)
|
|
171
|
+
"toolkit: #{name} (no suggestion: #{why}) · /toolkit to change"
|
|
172
|
+
end
|
|
173
|
+
|
|
158
174
|
# "toolkit: smoke (suggested, p=0.71) · /toolkit to change"; a
|
|
159
175
|
# deferred switch says "from this turn".
|
|
160
176
|
def self.suggestion_notice(name, confidence, deferred:)
|
|
@@ -192,7 +192,7 @@ module Samagotchi
|
|
|
192
192
|
# of a relay), else its hook; an approval's call (#what_text).
|
|
193
193
|
# @param question [PendingQuestion] the child's
|
|
194
194
|
def what(question)
|
|
195
|
-
return what_text(
|
|
195
|
+
return what_text(Guardrails::ApprovalFacts.from_h(question.approval)) unless question.kind.to_s == HOOK_KIND
|
|
196
196
|
|
|
197
197
|
inner = symbolized(question.relay, nil)
|
|
198
198
|
header = (inner ? inner[:header] : question.header).to_s
|
|
@@ -204,11 +204,12 @@ module Samagotchi
|
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
# execute: git push origin main
|
|
207
|
+
# @param facts [Guardrails::ApprovalFacts, nil] the saved card's
|
|
207
208
|
def what_text(facts)
|
|
208
|
-
facts
|
|
209
|
-
tool = facts
|
|
210
|
-
what = facts
|
|
211
|
-
what = facts
|
|
209
|
+
facts ||= Guardrails::ApprovalFacts.new
|
|
210
|
+
tool = facts.label || facts.tool || "tool"
|
|
211
|
+
what = facts.command || Array(facts.paths).join(", ")
|
|
212
|
+
what = facts.args.to_s if what.to_s.empty?
|
|
212
213
|
what.to_s.empty? ? tool.to_s : "#{tool}: #{what}"
|
|
213
214
|
end
|
|
214
215
|
|
|
@@ -337,13 +337,13 @@ module Samagotchi
|
|
|
337
337
|
"truncated=true",
|
|
338
338
|
"preview_strategy=head_tail",
|
|
339
339
|
"reply_bytes=#{text.bytesize}",
|
|
340
|
-
"returned_preview_bytes=#{preview
|
|
341
|
-
"omitted_bytes=#{preview
|
|
340
|
+
"returned_preview_bytes=#{preview.returned_preview_bytes}",
|
|
341
|
+
"omitted_bytes=#{preview.omitted_bytes}",
|
|
342
342
|
"[TRUNCATED_PREVIEW_HEAD]",
|
|
343
|
-
preview
|
|
344
|
-
"[... omitted #{preview
|
|
343
|
+
preview.head,
|
|
344
|
+
"[... omitted #{preview.omitted_bytes} bytes ...]",
|
|
345
345
|
"[TRUNCATED_PREVIEW_TAIL]",
|
|
346
|
-
preview
|
|
346
|
+
preview.tail
|
|
347
347
|
].join("\n")
|
|
348
348
|
end
|
|
349
349
|
end
|
|
@@ -469,13 +469,13 @@ module Samagotchi
|
|
|
469
469
|
"truncated=true",
|
|
470
470
|
"preview_strategy=head_tail",
|
|
471
471
|
"#{label}_bytes=#{bytes}",
|
|
472
|
-
"returned_preview_bytes=#{preview
|
|
473
|
-
"omitted_bytes=#{preview
|
|
472
|
+
"returned_preview_bytes=#{preview.returned_preview_bytes}",
|
|
473
|
+
"omitted_bytes=#{preview.omitted_bytes}",
|
|
474
474
|
"[TRUNCATED_PREVIEW_HEAD]",
|
|
475
|
-
preview
|
|
476
|
-
"[... omitted #{preview
|
|
475
|
+
preview.head,
|
|
476
|
+
"[... omitted #{preview.omitted_bytes} bytes ...]",
|
|
477
477
|
"[TRUNCATED_PREVIEW_TAIL]",
|
|
478
|
-
preview
|
|
478
|
+
preview.tail
|
|
479
479
|
].join("\n")
|
|
480
480
|
end
|
|
481
481
|
|
|
@@ -492,7 +492,7 @@ module Samagotchi
|
|
|
492
492
|
# A big log: its size, and the head and tail read by seek, which
|
|
493
493
|
# output_block shows as it shows a long string's.
|
|
494
494
|
class LogPreview
|
|
495
|
-
attr_reader :bytesize, :preview
|
|
495
|
+
attr_reader :bytesize, :preview # Integer, OutputGuardrails::PreviewParts
|
|
496
496
|
|
|
497
497
|
def initialize(bytesize, preview)
|
|
498
498
|
@bytesize = bytesize
|
|
@@ -11,6 +11,11 @@ module Samagotchi
|
|
|
11
11
|
DEFAULT_TELEMETRY_THRESHOLD_PCT = 80.0
|
|
12
12
|
DEFAULT_CHARS_PER_TOKEN = 4.0
|
|
13
13
|
|
|
14
|
+
# A large output's head and tail (UTF-8 safe) with the byte counts the
|
|
15
|
+
# truncated-preview result lines report.
|
|
16
|
+
PreviewParts = Data.define(:head, :tail, :head_bytes, :tail_bytes, :returned_preview_bytes,
|
|
17
|
+
:omitted_bytes, :total_bytes)
|
|
18
|
+
|
|
14
19
|
module_function
|
|
15
20
|
|
|
16
21
|
# The config setting +key+ (read.*, execute.*, context.*) when positive, else +default+.
|
|
@@ -41,6 +46,7 @@ module Samagotchi
|
|
|
41
46
|
.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
42
47
|
end
|
|
43
48
|
|
|
49
|
+
# @return [PreviewParts]
|
|
44
50
|
def head_tail_from_file(path:, file_size:, preview_bytes:)
|
|
45
51
|
half = [preview_bytes / 2, 1].max
|
|
46
52
|
head = File.binread(path, half, 0)
|
|
@@ -50,6 +56,7 @@ module Samagotchi
|
|
|
50
56
|
build_preview_parts(head: head, tail: tail, total_bytes: file_size)
|
|
51
57
|
end
|
|
52
58
|
|
|
59
|
+
# @return [PreviewParts]
|
|
53
60
|
def head_tail_from_string(content:, preview_bytes:)
|
|
54
61
|
half = [preview_bytes / 2, 1].max
|
|
55
62
|
head = content.byteslice(0, half) || ""
|
|
@@ -78,7 +85,7 @@ module Samagotchi
|
|
|
78
85
|
tail_bytes = tail.bytesize
|
|
79
86
|
omitted_bytes = [total_bytes - (head_bytes + tail_bytes), 0].max
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
PreviewParts.new(
|
|
82
89
|
head: safe_utf8(head),
|
|
83
90
|
tail: safe_utf8(tail),
|
|
84
91
|
head_bytes: head_bytes,
|
|
@@ -86,7 +93,7 @@ module Samagotchi
|
|
|
86
93
|
returned_preview_bytes: head_bytes + tail_bytes,
|
|
87
94
|
omitted_bytes: omitted_bytes,
|
|
88
95
|
total_bytes: total_bytes
|
|
89
|
-
|
|
96
|
+
)
|
|
90
97
|
end
|
|
91
98
|
private_class_method :build_preview_parts
|
|
92
99
|
end
|