samagotchi 0.134.0 → 0.136.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -63,13 +63,15 @@ module Samagotchi
63
63
 
64
64
  # #protected? on an approval's facts (Approval.payload's approval:),
65
65
  # at ask time.
66
+ # @param facts [ApprovalFacts, Hash, nil] as built, or as saved
66
67
  def protected_facts?(facts)
67
- return false unless facts.is_a?(Hash)
68
- return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
69
- return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
68
+ facts = ApprovalFacts.from_h(facts)
69
+ return false if facts.nil?
70
+ return true if PROTECTED_RULES.include?(facts.rule.to_s)
71
+ return true if facts.source.to_s == ProtectedPaths::SOURCE
70
72
  # A memory's file sits in the config dir, but removing one is the
71
73
  # memory-remove rule's ask, which a parent may answer.
72
- return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
74
+ return false if facts.rule.to_s == MEMORY_REMOVE_RULE
73
75
 
74
76
  chi_files?(facts)
75
77
  end
@@ -77,15 +79,17 @@ module Samagotchi
77
79
  # Whether an approval's paths or command name chi's own dirs (#chi_dirs)
78
80
  # or CHI_TEXT, whatever its rule: the part of #protected_facts? that
79
81
  # System 1's SystemOne::SelfProtect shares.
82
+ # @param facts [ApprovalFacts, Hash, nil] as built, or as saved
80
83
  def chi_files?(facts)
81
- return false unless facts.is_a?(Hash)
84
+ facts = ApprovalFacts.from_h(facts)
85
+ return false if facts.nil?
82
86
 
83
87
  dirs = chi_dirs
84
- paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
88
+ paths = Array(facts.paths).map(&:to_s).reject(&:empty?)
85
89
  reals = paths.flat_map { |path| PathResolve.reals(path) }
86
90
  return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
87
91
 
88
- command = fetch(facts, :command).to_s
92
+ command = facts.command.to_s
89
93
  return false if command.empty?
90
94
 
91
95
  command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(PathResolve.real(dir)) }
@@ -103,8 +107,7 @@ module Samagotchi
103
107
  # The offered scopes, in option order, or nil when they are missing,
104
108
  # empty, not strings, or leave no option after them for Deny.
105
109
  def scopes(pending)
106
- facts = pending.approval
107
- scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
110
+ scopes = ApprovalFacts.from_h(pending.approval)&.scopes
108
111
  return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
109
112
  return nil unless scopes.size < options(pending).size
110
113
 
@@ -112,12 +115,6 @@ module Samagotchi
112
115
  end
113
116
 
114
117
  def options(pending) = Array(pending.options)
115
-
116
- def fetch(hash, key)
117
- return nil unless hash.is_a?(Hash)
118
-
119
- hash.key?(key) ? hash[key] : hash[key.to_s]
120
- end
121
118
  end
122
119
  end
123
120
  end
@@ -17,7 +17,9 @@ module Samagotchi
17
17
  # sh -c, eval, timeout, …), a background &, a $ expansion other than
18
18
  # $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
19
19
  # expansion, a < or << redirection, a path to a verb (/bin/ls), and
20
- # less/more/vim (they run commands) are all not read-only.
20
+ # less/more/vim (they run commands) are all not read-only. test and
21
+ # [ … ] read only as one file or string check (.test_expr): `[[` and
22
+ # anything else they can do are not.
21
23
  #
22
24
  # .read_only_but_writes? (the child boundary's) also lets through the
23
25
  # writes it can name: a > or >> target, cp's destination and tee's
@@ -231,6 +233,7 @@ module Samagotchi
231
233
 
232
234
  writes&.walk&.step(args)
233
235
  verb = args.shift
236
+ return args.last == "]" && test_expr(args[0...-1]) if verb == "["
234
237
  return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
235
238
  return true if PLAIN.include?(verb)
236
239
  return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
@@ -276,6 +279,25 @@ module Samagotchi
276
279
 
277
280
  def rg(args) = args.none? { |a| a.start_with?("--pre") }
278
281
 
282
+ # test's checks that only look: a file's type, mode or size, a string
283
+ # being empty or not.
284
+ TEST_UNARY = %w[-e -f -d -s -r -w -x -L -h -b -c -p -S -g -u -k -O -G -n -z].freeze
285
+ # Comparisons of two strings or integers.
286
+ TEST_BINARY = %w[= == != -eq -ne -lt -le -gt -ge].freeze
287
+
288
+ # test's (and [ … ]'s, without the ]) expression when it is one check:
289
+ # an optional !, then a unary TEST_UNARY with its operand or a
290
+ # TEST_BINARY between two. Any other shape (-a/-o, ( ), -v, one bare
291
+ # word, a missing operand) is not read-only.
292
+ def test_expr(args)
293
+ args = args.drop(1) if args.first == "!"
294
+ case args.size
295
+ when 2 then TEST_UNARY.include?(args[0])
296
+ when 3 then TEST_BINARY.include?(args[1])
297
+ else false
298
+ end
299
+ end
300
+
279
301
  FIND_ACTIONS = %w[-delete -exec -execdir -ok -okdir -fprint -fprint0 -fprintf -fls].freeze
280
302
 
281
303
  def find(args) = args.none? { |a| FIND_ACTIONS.any? { |action| a.start_with?(action) } }
@@ -490,7 +512,7 @@ module Samagotchi
490
512
  CHECKS = {
491
513
  "rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
492
514
  "sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
493
- "git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
515
+ "git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh), "test" => method(:test_expr)
494
516
  }.freeze
495
517
 
496
518
  # A sed script parsed far enough to tell that it only reads: its
@@ -52,7 +52,7 @@ module Samagotchi
52
52
  def git_call(words)
53
53
  words = words.drop_while { |w| w.match?(/\A[A-Za-z_]\w*=/) || ShellGitDirs::PREFIXES.include?(w) }
54
54
  verb = words.first
55
- return nil unless verb == "git" || verb.to_s.end_with?("/git")
55
+ return nil unless ShellLex.program?(verb, "git")
56
56
 
57
57
  args = words.drop(1).grep_v(ShellLex::REDIRECT)
58
58
  until args.empty?
@@ -97,7 +97,7 @@ module Samagotchi
97
97
  @pushed << @dirs
98
98
  @dirs = args.first ? dirs_after(args.first) : [nil]
99
99
  when "popd" then @dirs = @pushed.pop unless @pushed.empty?
100
- else git(args, env) if verb == "git" || verb.end_with?("/git")
100
+ else git(args, env) if ShellLex.program?(verb, "git")
101
101
  end
102
102
  end
103
103
 
@@ -39,6 +39,28 @@ module Samagotchi
39
39
  Lexer.new(text).tokens
40
40
  end
41
41
 
42
+ # A program word's name as the guardrails compare it: its basename,
43
+ # case-folded (GIT, Git, /usr/bin/SH, ſh → git, sh), since a
44
+ # case-insensitive file system (macOS's default) runs `GIT` as git.
45
+ # Every check that tells a program by its name goes through this
46
+ # (PairCommand, ImplicitWriters, SelfProtect, ShellGitDirs, SharedGit).
47
+ # A word with a NUL (a SUBST) raises, as File.basename does.
48
+ def self.program_name(word)
49
+ name = File.basename(word.to_s)
50
+ name.valid_encoding? ? name.downcase(:fold) : name
51
+ end
52
+
53
+ # Whether +word+ runs the program +name+ (.program_name). A word with a
54
+ # NUL (a SUBST: "$(dirname $(which git))/git") by what follows its last
55
+ # `/`, folded the same way: no `/`, no name.
56
+ def self.program?(word, name)
57
+ word = word.to_s
58
+ return program_name(word) == name unless word.include?("\0")
59
+
60
+ last = word[%r{/([^/]*)\z}, 1] or return false
61
+ (last.valid_encoding? ? last.downcase(:fold) : last) == name
62
+ end
63
+
42
64
  # Whether +text+ ends inside a quote, a $(…) or a heredoc: its tokens
43
65
  # are a best guess.
44
66
  def self.unterminated?(text)
@@ -7,6 +7,7 @@ require_relative "cli/exit"
7
7
  require_relative "cli/flags"
8
8
  require_relative "config"
9
9
  require_relative "bridge/card_store"
10
+ require_relative "guardrails/approval"
10
11
  require_relative "guardrails/approvals"
11
12
  require_relative "guardrails/parent_policy"
12
13
  require_relative "host_registry"
@@ -71,7 +72,7 @@ module Samagotchi
71
72
  }.freeze
72
73
 
73
74
  # One ask a replay judges: the card's question id, the session it came
74
- # from, its approval as the card saved it, the label its answer gives,
75
+ # from, its approval as the card saved it (Guardrails::ApprovalFacts), the label its answer gives,
75
76
  # and when the user was asked.
76
77
  Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
77
78
 
@@ -208,17 +209,19 @@ module Samagotchi
208
209
 
209
210
  pending = entry[:pending_question]
210
211
  return :no_approval unless pending.is_a?(Hash) && pending[:approval].is_a?(Hash)
212
+
211
213
  # A participant's turn's ask is never System 1's to judge, live or here
212
214
  # (GuardrailWiring#system_one_judge): no record of it is the user's
213
215
  # rule-level decision (it offered once only).
214
- return :participant if pending[:approval][:author]
216
+ approval = Guardrails::ApprovalFacts.from_h(pending[:approval])
217
+ return :participant if approval.author
215
218
 
216
219
  question_id = pending[:id].to_s
217
220
  return :no_question_id if question_id.empty?
218
221
 
219
222
  origin = judged.question_ids[question_id]
220
223
  return origin == "replay" ? :already_replayed : :judged_live if origin
221
- return :judged_live if judged.take_live(session_id, pending[:approval])
224
+ return :judged_live if judged.take_live(session_id, approval)
222
225
 
223
226
  answer = entry[:answer]
224
227
  label = if entry[:cancelled]
@@ -230,15 +233,15 @@ module Samagotchi
230
233
  # label only where the parent could have allowed: a parent
231
234
  # that was not allowed to permit says nothing about the user
232
235
  # (docs/guardrails.md, "System 1").
233
- return :answered_by_parent unless label_for(answer, pending[:approval][:scopes]) == "deny"
236
+ return :answered_by_parent unless label_for(answer, approval.scopes) == "deny"
234
237
  return :parent_cannot_allow unless parent_may_permit?(pending, session_id, sessions_dir)
235
238
 
236
239
  "deny"
237
240
  else
238
- label_for(answer, pending[:approval][:scopes])
241
+ label_for(answer, approval.scopes)
239
242
  end
240
243
 
241
- Ask.new(question_id: question_id, session_id: session_id, approval: pending[:approval],
244
+ Ask.new(question_id: question_id, session_id: session_id, approval: approval,
242
245
  label: label, asked_at: pending[:created_at])
243
246
  end
244
247
 
@@ -298,15 +301,15 @@ module Samagotchi
298
301
  p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
299
302
 
300
303
  unless @dry_run
301
- id = log.judgment(session_id: ask.session_id, rule: ask.approval[:rule], source: ask.approval[:source],
302
- tool: ask.approval[:tool], **SystemOne::ApprovalQuestion.call_fields(ask.approval),
303
- repo_name: ask.approval[:repo_name], p: p, threshold: threshold, model: client.model,
304
+ id = log.judgment(session_id: ask.session_id, rule: ask.approval.rule, source: ask.approval.source,
305
+ tool: ask.approval.tool, **SystemOne::ApprovalQuestion.call_fields(ask.approval),
306
+ repo_name: ask.approval.repo_name, p: p, threshold: threshold, model: client.model,
304
307
  latency_ms: latency_ms, decision: "replay", origin: "replay",
305
308
  question_id: ask.question_id, asked_at: ask.asked_at)
306
309
  log.label(id, ask.label)
307
310
  end
308
311
 
309
- { rule: ask.approval[:rule].to_s, label: ask.label, p: p, session: ask.session_id }
312
+ { rule: ask.approval.rule.to_s, label: ask.label, p: p, session: ask.session_id }
310
313
  end
311
314
 
312
315
  # The session's title, as the ask's state carries it; nil when there is
@@ -429,8 +429,8 @@ module Samagotchi
429
429
  # The shape the engine persists: role and content (parts stay an
430
430
  # Array), plus a model turn's tool_calls and thinking (the host's
431
431
  # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
432
- # refs of a user message or a tool result, a prompt's turn_id (Engine,
433
- # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
432
+ # refs of a user message or a tool result, a prompt's turn_id and its
433
+ # Verify/Revise anchors (Engine, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
434
434
  # (the live row's params line and label, never sent back), an
435
435
  # edit/write result's tool_diffs (never sent), an empty-answer
436
436
  # note's marker (TurnNote.empty, never sent), and an answer's display
@@ -448,6 +448,7 @@ module Samagotchi
448
448
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
449
449
  message[:turn_start] = true if entry[:turn_start] == true
450
450
  message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
451
+ message[:revises] = entry[:revises] if entry[:revises].is_a?(Hash)
451
452
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
452
453
  message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
453
454
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
@@ -706,7 +707,7 @@ module Samagotchi
706
707
  end
707
708
 
708
709
  # The host's reasoning, kept on the model message as +thinking+ for
709
- # the web turn view's reload (the whole of it, as the live view
710
+ # the web turn block's reload (the whole of it, as the live block
710
711
  # shows), and sent back to the model as ChatLoop#wire_messages says
711
712
  # (reasoning_history). A wire that sends its blocks back keeps them
712
713
  # as +anthropic_content+ (ChatResponse#provider_content: string
@@ -13,7 +13,7 @@ require_relative "row_refs"
13
13
  require_relative "tool_output_cut"
14
14
 
15
15
  module Samagotchi
16
- # What a saved assistant message did, for a turn view's reload: its
16
+ # What a saved assistant message did, for the web turn block's reload: its
17
17
  # thinking and its tool calls (name, the params line the live row shows,
18
18
  # the output from the tool_response messages after it). Two storage
19
19
  # shapes, best effort:
@@ -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
@@ -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,6 +3,7 @@
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"
8
9
  require_relative "../guardrails/pair_command"
@@ -86,20 +87,21 @@ module Samagotchi
86
87
 
87
88
  module_function
88
89
 
89
- # @param approval [Hash, nil] the ask's approval facts (rule, tool,
90
- # 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
91
92
  # @param targets [Guardrails::Targets, nil] the call's targets, for its
92
93
  # env: (Targets#shell_envs, #redefines_shell?)
93
94
  # @return [Boolean] whether System 1 must not answer this ask
94
95
  def refuse?(approval, targets: nil)
95
- return false unless approval.is_a?(Hash)
96
- 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)
97
99
  # An env: that redefines the shell (BASH_ENV, BASH_FUNC_…) runs what
98
100
  # no word of the command says.
99
101
  return true if targets&.redefines_shell?
100
102
 
101
- command = fetch(approval, :command).to_s
102
- 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)
103
105
  return true if words&.chi
104
106
  return true if chi_paths?(approval)
105
107
  # The test that makes an ask user_only (ParentApprovals#chi_files?:
@@ -116,7 +118,7 @@ module Samagotchi
116
118
  memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
117
119
  bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
118
120
  dirs = chi_dirs
119
- Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?).any? do |path|
121
+ Array(approval.paths).map(&:to_s).reject(&:empty?).any? do |path|
120
122
  Guardrails::PathResolve.reals(path).any? do |real|
121
123
  dirs.any? { |dir| Guardrails::PathResolve.within?(real, dir) } && !memory?(real, memories, bundles)
122
124
  end
@@ -129,12 +131,12 @@ module Samagotchi
129
131
  def beyond_memories(approval, exempt:)
130
132
  memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
131
133
  bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
132
- paths = Array(fetch(approval, :paths)).map(&:to_s).reject do |path|
134
+ paths = Array(approval.paths).map(&:to_s).reject do |path|
133
135
  Guardrails::PathResolve.reals(path).all? { |real| memory?(real, memories, bundles) }
134
136
  end
135
- command = fetch(approval, :command).to_s
137
+ command = approval.command.to_s
136
138
  command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
137
- { paths: paths, command: command }
139
+ approval.with(paths: paths, command: command)
138
140
  end
139
141
 
140
142
  # Whether the command's memories text may be taken out: no command,
@@ -211,7 +213,7 @@ module Samagotchi
211
213
  simple.each_with_index.any? do |word, i|
212
214
  next false if word.include?("\0")
213
215
 
214
- verbs = FILE_RUNNERS.find { |pattern, _| File.basename(word).match?(pattern) }&.last
216
+ verbs = FILE_RUNNERS.find { |pattern, _| Guardrails::ShellLex.program_name(word).match?(pattern) }&.last
215
217
  verbs && (verbs.empty? || simple[(i + 1)..].intersect?(verbs))
216
218
  end
217
219
  end
@@ -240,10 +242,6 @@ module Samagotchi
240
242
  word = WORD_END.sub("[", "[^")
241
243
  Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{word}*)?")
242
244
  end
243
-
244
- def fetch(hash, key)
245
- hash.key?(key) ? hash[key] : hash[key.to_s]
246
- end
247
245
  end
248
246
  end
249
247
  end