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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. 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 for #last_warning.
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.running_here?
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 (a Verify turn's input): keeps the bundle test kit's
16
- # load budget (spec/samagotchi/testing/sandbox_spec.rb).
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, verify: nil, now: Time.now)
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, "verify" => VerifyAnchor.from(verify)&.to_file }.compact
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
- Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery, :verify) do
236
- def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil, verify: nil)
237
- super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:, verify: verify && VerifyAnchor.from(verify))
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, verify: data["verify"])
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 turn waits too:
286
- # merged into a running turn as a steer, its anchor would be lost.
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
- data["verify"].is_a?(Hash)
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, verify: 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, verify: verify)
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, verify: nil, state_dir: nil, manager: self, bridge: nil)
1457
- verify = VerifyAnchor.from(verify)
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
- return verify_unavailable if verify && !worker_has?(client, VerifyAnchor::FEATURE)
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[:verify] = verify if verify
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[:verify] = verify if verify
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]: the ask the user is shown
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[:rule],
47
- "source" => approval[:source],
48
- "reason" => approval[:reason],
49
- "tool" => approval[:tool],
50
- "command" => cut(approval[:command]),
51
- "paths" => Array(approval[:paths]).map { |path| cut(path) },
52
- "change" => change_text(approval[:preview]),
53
- "cwd" => approval[:cwd],
54
- "repo_name" => approval[:repo_name],
55
- "branch" => approval[:branch],
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[:memory]),
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[:rule], repo_name: approval[:repo_name],
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
- command = approval[:command]
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[:paths])
79
+ paths = Array(approval.paths)
75
80
  return paths.join("\n") unless paths.empty?
76
81
 
77
- approval[:args].to_s
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 approval facts (rule, tool,
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
- return false unless approval.is_a?(Hash)
75
- return true if NEVER_AUTO.include?(fetch(approval, :rule).to_s)
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 = fetch(approval, :command).to_s
81
- words = command.empty? ? nil : words(command, cwd: fetch(approval, :cwd), targets: targets)
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(fetch(approval, :paths)).map(&:to_s).reject(&:empty?).any? do |path|
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(fetch(approval, :paths)).map(&:to_s).reject do |path|
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 = fetch(approval, :command).to_s
137
+ command = approval.command.to_s
115
138
  command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
116
- { paths: paths, command: command }
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(symbolized(question.approval)) unless question.kind.to_s == HOOK_KIND
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 = {} unless facts.is_a?(Hash)
209
- tool = facts[:label] || facts[:tool] || "tool"
210
- what = facts[:command] || Array(facts[:paths]).join(", ")
211
- what = facts[:args].to_s if what.to_s.empty?
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[:returned_preview_bytes]}",
341
- "omitted_bytes=#{preview[:omitted_bytes]}",
340
+ "returned_preview_bytes=#{preview.returned_preview_bytes}",
341
+ "omitted_bytes=#{preview.omitted_bytes}",
342
342
  "[TRUNCATED_PREVIEW_HEAD]",
343
- preview[:head],
344
- "[... omitted #{preview[:omitted_bytes]} bytes ...]",
343
+ preview.head,
344
+ "[... omitted #{preview.omitted_bytes} bytes ...]",
345
345
  "[TRUNCATED_PREVIEW_TAIL]",
346
- preview[:tail]
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[:returned_preview_bytes]}",
473
- "omitted_bytes=#{preview[:omitted_bytes]}",
472
+ "returned_preview_bytes=#{preview.returned_preview_bytes}",
473
+ "omitted_bytes=#{preview.omitted_bytes}",
474
474
  "[TRUNCATED_PREVIEW_HEAD]",
475
- preview[:head],
476
- "[... omitted #{preview[:omitted_bytes]} bytes ...]",
475
+ preview.head,
476
+ "[... omitted #{preview.omitted_bytes} bytes ...]",
477
477
  "[TRUNCATED_PREVIEW_TAIL]",
478
- preview[:tail]
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