samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "answer_tail"
4
+ require_relative "steer"
5
+ require_relative "verify_anchor"
6
+
7
+ module Samagotchi
8
+ # Whether a participant may Revise a turn: the rule chi web checks at the
9
+ # send (Web::ParticipantAccess#participant_revise_refusal) and the worker
10
+ # again at the stamp (TurnStages::VerifyAnchors#revision_target), from the
11
+ # author the server stamped on each opening, never the body's. A turn
12
+ # they may revise is one of their own, answered, not a Verify turn, with
13
+ # only their own turns after it. The owner's Revise is never checked here.
14
+ # See docs/internals/participants.md (Turns).
15
+ module ParticipantRevise
16
+ module_function
17
+
18
+ # A turn's opening as the rule counts it: a prompt or a wake turn's
19
+ # note, never a stored `!shell` line (the owner's, which a participant
20
+ # doesn't see: ParticipantView drops it, so it counts for nothing).
21
+ def opening?(message) = Steer.turn_prompt?(message) && !Steer.shell_line?(message)
22
+
23
+ # Why +author+ ("p:<id>") may not revise the turn +turn_id+ in
24
+ # +messages+, or nil when they may:
25
+ # - :not_own: no opening carries that id, or it isn't theirs, has no
26
+ # answer, or is a Verify turn (a missing turn answers the same as
27
+ # someone else's: no existence oracle);
28
+ # - :overtaken: a turn opening after it isn't theirs (the owner's,
29
+ # another participant's, a wake turn's).
30
+ # +before+: the Revise turn's own id, whose opening and everything after
31
+ # it are ignored (the worker's check, the turn already in the messages).
32
+ # @return [Symbol, nil]
33
+ def refusal(messages, turn_id:, author:, before: nil)
34
+ list = Array(messages)
35
+ author = Steer.sender_author(author)
36
+ return :not_own unless author
37
+
38
+ start = list.rindex { |m| opening?(m) && field(m, :turn_id).to_s == turn_id.to_s }
39
+ return :not_own unless start && own_answered?(list, start, author)
40
+
41
+ list[(start + 1)..].each do |message|
42
+ next unless opening?(message)
43
+ break if before && field(message, :turn_id).to_s == before.to_s
44
+ return :overtaken unless Steer.sender_author(field(message, :author)) == author
45
+ end
46
+ nil
47
+ end
48
+
49
+ def own_answered?(list, start, author)
50
+ opening = list[start]
51
+ return false unless Steer.sender_author(field(opening, :author)) == author
52
+ return false if VerifyAnchor.marks?(field(opening, :verify))
53
+
54
+ # Its slice, as AnswerTail.turn_slice bounds one: up to the next prompt.
55
+ stop = list.each_index.find { |i| i > start && Steer.turn_prompt?(list[i]) } || list.size
56
+ list[start...stop].any? { |message| AnswerTail.answer?(message) }
57
+ end
58
+
59
+ def field(message, key) = message[key] || message[key.to_s]
60
+ end
61
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "atomic_file"
6
+ require_relative "log"
7
+
8
+ module Samagotchi
9
+ # Which image files of a session's images/ a participant uploaded: the
10
+ # one thing that lets a participant name an image in their turn or read
11
+ # it back (chi web's ParticipantImages). ImageStore is content-addressed
12
+ # and keeps no metadata, so ownership is chi web's own record, written
13
+ # only here, never derived from a ref's source, a name or a message.
14
+ # Keyed by (file, grant): an owner's or another grant's identical image
15
+ # never changes hands. Also each grant's upload attempts, accepted or
16
+ # refused (Grants::MAX_IMAGE_ATTEMPTS).
17
+ #
18
+ # participant_images.json in the session folder (beside participants.json,
19
+ # not in images/, which the image route serves), written whole
20
+ # (AtomicFile). Callers serialize read-then-write themselves (chi web's
21
+ # @participant_images_mutex). A file that doesn't read fails closed: every
22
+ # grant is at quota and owns nothing (Log.warn); the owner can delete it.
23
+ # See docs/internals/participants.md.
24
+ module ParticipantUploads
25
+ FILE = "participant_images.json"
26
+
27
+ # One upload: +file+ is the ref's "images/<hash>.<ext>".
28
+ Entry = Data.define(:file, :grant_id, :at)
29
+
30
+ # What the file holds; +broken+ when it doesn't read (then every grant
31
+ # is at quota and at its attempts cap, and owns nothing). Read it once
32
+ # per check and ask it (#count, #attempts, #owned?).
33
+ State = Data.define(:entries, :attempts, :broken) do
34
+ def count(grant_id) = broken ? Float::INFINITY : entries.count { |entry| entry.grant_id == grant_id }
35
+ def attempts_of(grant_id) = broken ? Float::INFINITY : attempts.fetch(grant_id, 0)
36
+ def owned?(file, grant_id) = entries.any? { |entry| entry.file == file && entry.grant_id == grant_id }
37
+ end
38
+ BROKEN = State.new(entries: [].freeze, attempts: {}.freeze, broken: true)
39
+
40
+ module_function
41
+
42
+ # @return [State]
43
+ def read(dir)
44
+ path = File.join(dir, FILE)
45
+ return State.new(entries: [], attempts: {}, broken: false) unless File.exist?(path)
46
+
47
+ data = JSON.parse(File.read(path))
48
+ raise TypeError, "not an object" unless data.is_a?(Hash)
49
+
50
+ entries = Array(data["images"]).map do |hash|
51
+ Entry.new(file: hash.fetch("file").to_s, grant_id: hash.fetch("grant_id").to_s, at: hash["at"].to_s)
52
+ end
53
+ attempts = data["attempts"] || {}
54
+ raise TypeError, "attempts not an object" unless attempts.is_a?(Hash)
55
+
56
+ State.new(entries: entries, attempts: attempts.transform_values(&:to_i), broken: false)
57
+ rescue JSON::ParserError, SystemCallError, TypeError, KeyError, NoMethodError => e
58
+ Log.warn(:web, "participant_images_unreadable", file: path, msg: e.message)
59
+ BROKEN
60
+ end
61
+
62
+ # Distinct files +grant_id+ uploaded; a broken record reads as at quota.
63
+ def count(dir, grant_id) = read(dir).count(grant_id)
64
+
65
+ # Uploads +grant_id+ tried, refused ones counted; a broken record reads
66
+ # as at the cap.
67
+ def attempts(dir, grant_id) = read(dir).attempts_of(grant_id)
68
+
69
+ # Whether +grant_id+ uploaded +file+ ("images/<hash>.<ext>").
70
+ def owned?(dir, file, grant_id) = read(dir).owned?(file, grant_id)
71
+
72
+ # Records +file+ as +grant_id+'s (once: an owned file adds nothing).
73
+ # A broken record is left as it is. +state+: the record as the caller
74
+ # just read it (under its lock), else read here.
75
+ # @return [Boolean] whether an entry was added
76
+ def add(dir, file:, grant_id:, at: Time.now, state: read(dir))
77
+ return false if state.broken
78
+ return false if state.owned?(file, grant_id)
79
+
80
+ write(dir, state.entries + [Entry.new(file: file, grant_id: grant_id, at: at.utc.iso8601)], state.attempts)
81
+ true
82
+ end
83
+
84
+ # Counts one upload attempt of +grant_id+. A broken record is left as it
85
+ # is. +state+ as #add's.
86
+ def attempt!(dir, grant_id, state: read(dir))
87
+ return if state.broken
88
+
89
+ write(dir, state.entries, state.attempts.merge(grant_id => state.attempts.fetch(grant_id, 0) + 1))
90
+ end
91
+
92
+ def write(dir, entries, attempts)
93
+ body = { "images" => entries.map { |entry| entry.to_h.transform_keys(&:to_s) }, "attempts" => attempts }
94
+ AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate(body)}\n", perm: 0o600)
95
+ end
96
+ private_class_method :write
97
+ end
98
+ end
@@ -92,7 +92,11 @@ module Samagotchi
92
92
  return unless @turn && !@claimed
93
93
 
94
94
  waiting = SessionInbox.waiting_input_data(@session_dir, @turn)
95
- @own_turn_input ||= !waiting.nil? && SessionInbox.turn_end_data?(waiting)
95
+ # A participant's next-step message may go into their own running
96
+ # turn (its author's), so it runs a turn of its own only when it
97
+ # would wait even there.
98
+ @own_turn_input ||= !waiting.nil? &&
99
+ SessionInbox.turn_end_data?(waiting, running_author: Steer.sender_author(waiting["author"]))
96
100
  @participant_input ||= !waiting.nil? && Steer.participant_author?(waiting["author"])
97
101
  @claimed = waiting.nil?
98
102
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "participant_messages"
3
4
  require_relative "verify_anchor"
4
5
 
5
6
  module Samagotchi
@@ -26,6 +27,14 @@ module Samagotchi
26
27
  end
27
28
 
28
29
  ReviseAnchor::FEATURE = "revise"
30
+ # A worker that checks a participant's Revise (ParticipantRevise, at the
31
+ # stamp) and keeps their draft: what chi web needs to pass one on.
32
+ ReviseAnchor::PARTICIPANT_FEATURE = "participant_revise"
33
+ # A participant's Revise to a live worker without PARTICIPANT_FEATURE
34
+ # (SessionManager::Delivery), in their words, never UNAVAILABLE's owner ones.
35
+ ReviseAnchor::PARTICIPANT_UNAVAILABLE = {
36
+ "error" => "revise_unavailable", "detail" => ParticipantMessages::REVISE_UNAVAILABLE
37
+ }.freeze
29
38
  ReviseAnchor::UNAVAILABLE = {
30
39
  "error" => "revise_unavailable",
31
40
  "detail" => "this session's worker predates Revise turns; restart this session to use Revise"
@@ -221,6 +221,11 @@ module Samagotchi
221
221
 
222
222
  UNKNOWN_VISION = VisionSupport::Answer.new(value: nil, reason: nil)
223
223
 
224
+ # .vision_refusal for +model_name+ (+typed+: as the user typed it) on
225
+ # .vision_answer's answer: chi web's turn and create routes and a
226
+ # participant's turn ask this one helper.
227
+ def self.vision_refusal_for(model_name, typed: nil) = vision_refusal(model_name, vision_answer(model_name, typed: typed))
228
+
224
229
  # What to say when +answer+ (.vision_answer's for +model_name+) is a
225
230
  # no, or nil: the words chi send and chi web's turn and create routes
226
231
  # refuse images with.
@@ -313,15 +313,18 @@ module Samagotchi
313
313
  # Whether a mid-turn drain leaves this unclaimed input file for a turn
314
314
  # of its own instead of merging it at a step boundary: a queued message
315
315
  # (`delivery: queue`), an image message (steering merges text only), or
316
- # a participant's, whatever its delivery says: theirs always runs as its
317
- # own turn, never inside another author's (D6; chi web queues it, and
318
- # this is the worker's own layer).
316
+ # one whose author isn't the running turn's: a participant's goes into
317
+ # their own running turn only, never another author's, and nobody's
318
+ # goes into a participant's but theirs (D6; the Bridge queues it, and
319
+ # this is the worker's own layer). +running_author+: the running turn's
320
+ # participant author (TurnOrigin#participant_author), nil for the
321
+ # owner's.
319
322
  # The drain skips such a file without stopping at it, so a later text
320
323
  # line still merges; the drain's file filter reads this one predicate,
321
324
  # so what a boundary leaves changes in one place.
322
- def self.waits_for_turn_end?(input_file)
325
+ def self.waits_for_turn_end?(input_file, running_author: nil)
323
326
  data = JSON.parse(File.read(input_file))
324
- data.is_a?(Hash) && turn_end_data?(data)
327
+ data.is_a?(Hash) && turn_end_data?(data, running_author: running_author)
325
328
  rescue JSON::ParserError, SystemCallError
326
329
  false
327
330
  end
@@ -339,10 +342,13 @@ module Samagotchi
339
342
 
340
343
  # A parsed input file's #waits_for_turn_end?. A Verify or a Revise turn
341
344
  # waits too: merged into a running turn as a steer, its anchor would be
342
- # lost.
343
- def self.turn_end_data?(data)
344
- Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"]) ||
345
- TurnAnchors.named_in?(data)
345
+ # lost. Without +running_author+ (the owner's turn, or none named) a
346
+ # participant's file waits, as before.
347
+ def self.turn_end_data?(data, running_author: nil)
348
+ return true if Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || TurnAnchors.named_in?(data)
349
+
350
+ author = Steer.sender_author(data["author"])
351
+ running_author ? author != running_author : Steer.participant_author?(data["author"])
346
352
  end
347
353
 
348
354
  # Whether a message waits that a running turn's next boundary merges and
@@ -352,11 +358,13 @@ module Samagotchi
352
358
  # ends on it (Worker#input_waiting?). It only peeks: nothing is claimed;
353
359
  # a file the drain or a dequeue claims meanwhile (ENOENT) or one that
354
360
  # doesn't parse is skipped.
355
- def self.steer_waiting?(session_dir)
361
+ # In a participant's turn (+running_author+) only their own message
362
+ # counts.
363
+ def self.steer_waiting?(session_dir, running_author: nil)
356
364
  find_new_input_files(session_dir).any? do |file|
357
365
  data = JSON.parse(File.read(file))
358
- data.is_a?(Hash) && !turn_end_data?(data) && !data["prompt"].to_s.strip.empty? &&
359
- Steer.cuts?(Steer.source_for_client(data["client_id"]))
366
+ data.is_a?(Hash) && !turn_end_data?(data, running_author: running_author) && !data["prompt"].to_s.strip.empty? &&
367
+ (running_author || Steer.cuts?(Steer.source_for_client(data["client_id"])))
360
368
  rescue JSON::ParserError, SystemCallError
361
369
  false
362
370
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../turn_anchors"
4
+
3
5
  module Samagotchi
4
6
  class SessionManager
5
7
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -221,6 +223,11 @@ module Samagotchi
221
223
  return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
222
224
  # A participant's turn only to a worker with the review boundary.
223
225
  return review_unavailable if Steer.participant_author?(author) && !worker_has?(client, Delivery::REVIEW_FEATURE)
226
+ # A participant's Revise only to a worker that checks it at the
227
+ # stamp (ParticipantRevise): an older one would stamp it unchecked.
228
+ if Steer.participant_author?(author) && anchors.revises && !worker_has?(client, ReviseAnchor::PARTICIPANT_FEATURE)
229
+ return DeliverResult.new(status: :refused, code: 409, ack: ReviseAnchor::PARTICIPANT_UNAVAILABLE)
230
+ end
224
231
 
225
232
  begin
226
233
  options = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id }
@@ -27,6 +27,8 @@ module Samagotchi
27
27
  continue: :context, corpus_dir: CONTEXT),
28
28
  Store.new(file: "participants.json", owner: "Participants", removal: :participants, cross_version: true,
29
29
  continue: "a continue ends the previous link's review", corpus_dir: SIDE_CAR),
30
+ Store.new(file: "participant_images.json", owner: "ParticipantUploads", removal: :session_files, cross_version: false,
31
+ continue: "a continue ends the previous link's review", corpus_dir: nil),
30
32
  Store.new(file: "judgments.jsonl", owner: "SystemOne::RunsStore", removal: :system_one_runs, cross_version: false,
31
33
  continue: "saved judgments belong to the previous link", corpus_dir: nil),
32
34
  Store.new(file: "context_candidates.jsonl", owner: "ContextCandidatesLog", removal: :context_candidates_log,
@@ -29,7 +29,9 @@ module Samagotchi
29
29
  # loaded, stamp) are nil. +loaded+: the tools a find_tools hit loads
30
30
  # (Tools::ToolIndex::FoundText#loaded), by their wire names. +stamp+: an
31
31
  # ok read/edit/write's file right after it ran ({path:, mtime_ns:,
32
- # size:}), what WorkingSet tells a file changed since by.
32
+ # size:}), what WorkingSet tells a file changed since by; for an ok
33
+ # memory_read, one entry per name it read, with the file that answered and
34
+ # the model overlay read with it (ToolRunner#memory_stamp).
33
35
  ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
34
36
  :output_file, :loaded, :stamp) do
35
37
  def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
@@ -205,7 +207,8 @@ module Samagotchi
205
207
  images: images&.any? ? images : nil,
206
208
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
207
209
  shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
208
- loaded: result[:loaded], stamp: status == "ok" ? file_stamp(call, context) : nil)
210
+ loaded: result[:loaded],
211
+ stamp: status == "ok" ? file_stamp(call, context) || memory_stamp(result) : nil)
209
212
  end
210
213
 
211
214
  private
@@ -216,12 +219,43 @@ module Samagotchi
216
219
  # can't place and a stat that fails or isn't a file's.
217
220
  def file_stamp(call, context)
218
221
  ref = FileRef.for(call[:name], call, cwd: session_cwd(context))
219
- return nil unless ref
222
+ ref ? stat_entry(ref.path) : nil
223
+ end
224
+
225
+ # A memory_read's stamp, one entry per name it read: the file that
226
+ # answered it, and the model overlay read with it when there was one, as
227
+ # they are right after the call:
228
+ # {memories: {name => {path:, mtime_ns:, size:, overlay: {path:, mtime_ns:,
229
+ # size:}}}} (no +overlay+ key when no overlay was read). Nothing at the
230
+ # top carries mtime_ns, so a reader that knows only file stamps
231
+ # (WorkingSet.stamp_of) sees none here, and a session saved before memory
232
+ # stamps has none: either way its memories are never stale (compat N-1).
233
+ # nil when the read answered no name (the index listing, every name
234
+ # missing), or none of its files can be stat'd.
235
+ def memory_stamp(result)
236
+ reads = result[:memory_reads]
237
+ return nil unless reads.is_a?(Hash)
238
+
239
+ entries = reads.filter_map do |name, read|
240
+ entry = stat_entry(read[:path])
241
+ next if entry.nil?
242
+
243
+ overlay = stat_entry(read[:overlay_path])
244
+ entry[:overlay] = overlay if overlay
245
+ [name, entry]
246
+ end
247
+ entries.empty? ? nil : { memories: entries.to_h }
248
+ end
249
+
250
+ # A file as it is now: {path:, mtime_ns:, size:}; nil for a path that is
251
+ # not a file or cannot be stat'd.
252
+ def stat_entry(path)
253
+ return nil if path.to_s.empty?
220
254
 
221
- stat = File.stat(ref.path)
255
+ stat = File.stat(path)
222
256
  return nil unless stat.file?
223
257
 
224
- { path: ref.path, mtime_ns: (stat.mtime.to_i * 1_000_000_000) + stat.mtime.nsec, size: stat.size }
258
+ { path: path, mtime_ns: (stat.mtime.to_i * 1_000_000_000) + stat.mtime.nsec, size: stat.size }
225
259
  rescue SystemCallError
226
260
  nil
227
261
  end
@@ -25,37 +25,53 @@ module Samagotchi
25
25
 
26
26
  SEPARATOR = "\n\n---\n\n"
27
27
 
28
+ # A memory_read's text, and which file answered each name it read
29
+ # (+reads+: name => {path:, overlay_path:}, absolute paths, the overlay
30
+ # only when one was read with the entry). A String, so every reader of
31
+ # the result reads the same bytes the model does; ToolRunner turns
32
+ # +reads+ into the run's stamp (ToolRunner#memory_stamp), which is what
33
+ # later tells a memory the model read from one whose file changed after.
34
+ class Result < String
35
+ attr_reader :reads
36
+
37
+ def initialize(text, reads = {})
38
+ super(text)
39
+ @reads = reads
40
+ end
41
+ end
42
+
28
43
  # @param fallback_model_key [String, nil] read when +model_key+ has no overlay
29
44
  def self.call(entry_name, scope: nil, model_key: nil, fallback_model_key: nil)
30
45
  entry_name = entry_name.to_s.strip
31
46
  scope = normalize_scope(scope)
32
47
 
33
48
  if entry_name.empty?
34
- return scoped_index(scope) + (scope == "project" ? orphan_hint : "") if scope
49
+ return Result.new(scoped_index(scope) + (scope == "project" ? orphan_hint : "")) if scope
35
50
 
36
51
  project = scoped_index("project") + orphan_hint
37
52
  system = scoped_index("system")
38
- return [
53
+ return Result.new([
39
54
  "Project memories:",
40
55
  project,
41
56
  "",
42
57
  "System memories:",
43
58
  system
44
- ].join("\n")
59
+ ].join("\n"))
45
60
  end
46
61
 
47
62
  names = parse_names(entry_name)
48
63
 
49
64
  if names.empty?
50
- return "Error: no memory names provided"
65
+ return Result.new("Error: no memory names provided")
51
66
  end
52
67
 
53
68
  bad = names.find { |name| invalid_name?(name) }
54
- return invalid_name_error(bad) if bad
69
+ return Result.new(invalid_name_error(bad)) if bad
55
70
 
56
71
  resolved_scopes = scope ? [scope] : %w[project system]
57
72
 
58
73
  results = []
74
+ reads = {}
59
75
  missing = []
60
76
 
61
77
  names.each do |name|
@@ -65,27 +81,30 @@ module Samagotchi
65
81
  next unless File.exist?(path)
66
82
 
67
83
  body = File.read(path, encoding: "UTF-8")
84
+ overlay = nil
68
85
  # Append model-specific overlay if key is provided and overlay exists.
69
86
  [model_key, fallback_model_key].compact.each do |key|
70
87
  overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
71
88
  next unless overlay_path && File.exist?(overlay_path)
72
89
 
73
90
  body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
91
+ overlay = overlay_path
74
92
  break
75
93
  end
76
94
  results << body
95
+ reads[name] = { path: path, overlay_path: overlay }
77
96
  found = true
78
97
  break
79
98
  end
80
99
  missing << name unless found
81
100
  end
82
101
 
83
- return results.join(SEPARATOR) if missing.empty?
102
+ return Result.new(results.join(SEPARATOR), reads) if missing.empty?
84
103
 
85
104
  # The hint goes after the first line: WorkingSet reads the missing
86
105
  # names off it (WorkingSet::MISSING) and a failure off its "Error".
87
106
  miss = "Error: memory not found: #{missing.join(", ")}#{orphan_hint if resolved_scopes.include?("project")}"
88
- results.empty? ? miss : results.join(SEPARATOR) + SEPARATOR + miss
107
+ results.empty? ? Result.new(miss) : Result.new(results.join(SEPARATOR) + SEPARATOR + miss, reads)
89
108
  rescue Errno::ENOENT => e
90
109
  "Error: #{e.message}"
91
110
  rescue StandardError => e
@@ -42,8 +42,5 @@ module Samagotchi
42
42
 
43
43
  # Engine#run_turn's keywords.
44
44
  def kwargs = { verify:, revises: }.compact
45
-
46
- # A participant's: they can't Revise (participant_access.rb, the Bridge).
47
- def without_revises = with(revises: nil)
48
45
  end
49
46
  end
@@ -4,6 +4,7 @@ require "time"
4
4
  require_relative "../answer_tail"
5
5
  require_relative "../log"
6
6
  require_relative "../output_formatter"
7
+ require_relative "../participant_revise"
7
8
  require_relative "../redact"
8
9
  require_relative "../revise_anchor"
9
10
  require_relative "../session"
@@ -90,7 +91,12 @@ module Samagotchi
90
91
  # anything. A draft no prompt in the messages carries is no target (a
91
92
  # stale page, a rolled-back turn); one a stamped turn already revised
92
93
  # is re-targeted to its stack's top, the newest version, which the
93
- # person meant ("this draft").
94
+ # person meant ("this draft"). A participant's draft first meets
95
+ # ParticipantRevise (theirs, with only their own turns after it, up
96
+ # to this turn): chi web checked it at the send, and this is the
97
+ # check at the stamp, so a turn someone else's overtook since runs
98
+ # as a plain turn (logged). The re-target walk then stays inside
99
+ # their own turns.
94
100
  def revision_target(messages, turn, outcome)
95
101
  return unless turn.revises && outcome == "completed"
96
102
 
@@ -98,6 +104,12 @@ module Samagotchi
98
104
  return unless slice&.any? { |message| AnswerTail.answer?(message) }
99
105
 
100
106
  target = turn.revises.turn_id
107
+ author = turn.origin&.author
108
+ if Steer.participant_author?(author) &&
109
+ (refused = ParticipantRevise.refusal(messages, turn_id: target, author: author, before: turn.id))
110
+ return reject_revise(turn.revises, refused)
111
+ end
112
+
101
113
  seen = [turn.id]
102
114
  until seen.include?(target)
103
115
  return reject_revise(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
@@ -32,5 +32,11 @@ module Samagotchi
32
32
  "detail" => "this session's worker predates Verify turns; restart this session to use Verify"
33
33
  }.freeze
34
34
 
35
+ # Whether a turn opening's +verify+ marks a Verify turn: a well-formed
36
+ # claim (#from), or the display's `verify: true` (MessageDisplay). One
37
+ # test for ParticipantRevise and the participant's view, so a malformed
38
+ # one reads the same to both (not a Verify turn).
39
+ def VerifyAnchor.marks?(value) = value == true || !VerifyAnchor.from(value).nil?
40
+
35
41
  def VerifyAnchor.worker_unavailable?(features) = !features.nil? && !features.include?(VerifyAnchor::FEATURE)
36
42
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.170.0"
4
+ VERSION = "0.171.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end