samagotchi 0.130.1 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcca1391e17e266cf83e1c56dec7ab52d21a1928a4c5190d2bf594884cca936f
4
- data.tar.gz: fc59a582d279b19352c3bda61bb5d2fc3796ae0e079e52b3dcb26dc85988ea33
3
+ metadata.gz: 37f2a54062786637083ade10186ebb751a1cfd7a7996540defa03da018b29533
4
+ data.tar.gz: 6836e60f2a87358698adaf000592ac68af5ce8fe8f3c26799825fa26641d3f1a
5
5
  SHA512:
6
- metadata.gz: b5cc1d80ca1b0d64f809360e111bdae2778772c5ca3c8cae7d6f339f650a55fb65b796ccc5ca3038b25c637223568a7facbc90a351ac5df77bee25c24fac3e59
7
- data.tar.gz: 4e149d518537a529c69494f14b0d07435c6e3327d27ae34a42c0e30504c74fee543282c4023fbd44587a301f189c24bd7d3aebdff40235784bd45f95cfd63217
6
+ metadata.gz: b908ad2c276d4dabb1c8bd64ad8435a2f9ab5408663243f8cd0f24e25ca2e4647cb356228a7a37027c1c5f2895936c4df8424af5203d8c121b5403577e2a2456
7
+ data.tar.gz: a3c96161fc7cb55e200367417e1dac93442845e0a868ad02d58ecca15a504af6824c419e8188252c9100daf405892be203e75fd6d7baaca1406c20186a4ff6f4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.131.0] - 2026-10-29
11
+
12
+ ### Changed
13
+
14
+ - Cache: confirmations for prompt-cache changes estimate the reread size when known and warn that Anthropic drops
15
+ preserved thinking after a full-prefix change.
16
+
17
+ ### Fixed
18
+
19
+ - Web: a Verify verdict card sits right after the answer it quotes, on your page and a participant's, instead of
20
+ under the turn's prompt, screens above the claim in a long turn. A quote the page can't find yet puts the card at
21
+ the turn's end.
22
+ - Web: Verify on a claim in a model's reply on a participant's page records its verdict and shows the card again;
23
+ the claim was sent without its turn, so the check ran as a plain comment.
24
+ - Web: after a fresh verdict lands on an unclear card, its button reads **Verify fresh again**: each press starts
25
+ another paid check (a participant's counts against their cap).
26
+ - Web: Verify is offered only on a finished turn's final answer, on your page and a participant's: the one place a
27
+ verdict card can come from. Selecting an earlier reply, a step, a message, a line added mid-turn or a turn still
28
+ running no longer offers a Verify that silently ran as a plain comment; the bar keeps its other actions there.
29
+ - Web: on a participant's page, a turn a delegate's report or a context note woke opens as "An automatic turn"
30
+ (still without the report or note). Its answer read as one more reply of the turn before: Verify was offered on
31
+ it and refused, and hidden on that turn's real final answer.
32
+ - Web: Verify on a claim that names a file in the session's folder or your home folder (or holds a key) records its
33
+ verdict: the pages quote such a path relative, or with `~`, and the check compared it against the answer's full
34
+ path, so it ran as a plain comment with no card.
35
+
36
+ Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`).
37
+
10
38
  ## [0.130.1] - 2026-10-29
11
39
 
12
40
  ### Fixed
@@ -4964,7 +4992,8 @@ and long-lived sessions.
4964
4992
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4965
4993
  hotkey panel that send selected text or the clipboard to your sessions.
4966
4994
 
4967
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.130.1...HEAD
4995
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.131.0...HEAD
4996
+ [0.131.0]: https://github.com/dm1try/samagotchi/compare/v0.130.1...v0.131.0
4968
4997
  [0.130.1]: https://github.com/dm1try/samagotchi/compare/v0.130.0...v0.130.1
4969
4998
  [0.130.0]: https://github.com/dm1try/samagotchi/compare/v0.129.0...v0.130.0
4970
4999
  [0.129.0]: https://github.com/dm1try/samagotchi/compare/v0.128.0...v0.129.0
data/docs/cli.md CHANGED
@@ -1214,7 +1214,9 @@ pills there (a review turn refuses `/` lines).
1214
1214
 
1215
1215
  After the presets, both pages have a built-in **Verify**: the quote, then
1216
1216
  an ask to check the claim against the code and end on a line `Verdict:
1217
- confirmed|refuted|unclear — <reason>`. A session whose worker predates Verify (started by an older chi) refuses it (409
1217
+ confirmed|refuted|unclear — <reason>`. Verify is offered only on text in a finished turn's final answer, the
1218
+ claim a verdict card can come from; elsewhere (an earlier reply, a step, a message, a line added mid-turn, a turn
1219
+ still running) the bar keeps its other actions. A session whose worker predates Verify (started by an older chi) refuses it (409
1218
1220
  `verify_unavailable`); a participant's page disables Verify and Verify fresh with a hint to restart the session. On your page a selection in a turn
1219
1221
  also offers **Copy link**: a link to that turn and quote for a participant's
1220
1222
  browser (the one their review link was opened in), which opens their page
@@ -838,7 +838,10 @@ Gemma's thinking switch, a remote chat host's thinking fields ("Thinking"), a ch
838
838
  reply has thinking ("Reasoning history"), and any change to the declared tools, which start every request
839
839
  ("Toolkits"). They ask only when what the next turn runs under really changes and the session has a model reply.
840
840
 
841
- - The reply says what it costs and changes nothing yet: `Nothing changed yet: send /thinking low --yes to apply.`
841
+ - The reply says what it costs and changes nothing yet. When known, it estimates the reread size: the whole
842
+ conversation for full-prefix changes, or from the first changed reply for reasoning history. On `api: anthropic`,
843
+ a full-prefix change also says preserved thinking in replies is dropped from then on.
844
+ - `Nothing changed yet: send /thinking low --yes to apply.` names the line that applies it.
842
845
  - `--yes` (or `-y`, anywhere after the command) applies it at once.
843
846
  - The terminal (the REPL and an attached TUI) puts the `--yes` line in the next input: Enter applies it.
844
847
  - The web's reply bubble has an **Apply (restarts the cache)** button. The session bar's chips are already an explicit
@@ -375,7 +375,10 @@ fields: a field added to an event later reaches nobody until it is named there.
375
375
  so the owner's `!` line and its output, a delegate's report (a wake turn's too), and the notes chi adds (a declined
376
376
  Continue) never show. A turn no person sent (its record's `automatic`, `SessionMetrics`: a task's or a plugin's,
377
377
  read from analytics.json and the live worker's records; the running turn's by its start; a delegate child's first
378
- turn, its parent's task) shows as an automatic turn without its prompt, as the stream does. A raw loop's saved
378
+ turn, its parent's task) shows as an automatic turn without its prompt, as the stream does; so does a wake turn's
379
+ start (`.wake_start`: the delegate report or the context note a wake turn opens with is dropped, its `turn_id` and
380
+ `at` kept), so its answer is never read as one more reply of the turn before (Verify, verdict cards and claim
381
+ links group by `[data-turn-id]`). A raw loop's saved
379
382
  reply is cut before the display reads it (`.visible_raw`): what came before a bare `</think>` and everything from
380
383
  an unclosed thought or tool call on, which the display would only untag. Each
381
384
  prompt carries its author and an image count; replies their text and their step's tool rows. Notes, steers and
@@ -428,6 +431,13 @@ fields: a field added to an event later reaches nobody until it is named there.
428
431
  After the presets comes the built-in **Verify** (`verify_preset.js`, on the owner's page too): the quote, then
429
432
  "Verify this claim against the code. If this session's context doesn't settle it, say so. End with a line
430
433
  `Verdict: confirmed|refuted|unclear — <one-line reason>`." It fills the box like any preset and never sends.
434
+ It is offered only where the Engine will take the claim (`verify_target.js`, the bar's `offeredPreset`): a
435
+ selection in the final answer of an ended turn, the last answer bubble (the owner's `.bubble.output` that isn't a
436
+ delegate report's reply; a participant's last `.p-msg.reply > .p-bubble`) between the turn's opening
437
+ (`[data-turn-id]`) and the next one (or, on the owner's page, a context note: a wake turn opens with one; a
438
+ participant's page gets a wake turn's opening as an automatic turn).
439
+ Anywhere else (an earlier reply, a step, a prompt, a line merged mid-turn, the Plan, the running turn) the button
440
+ is hidden and the bar keeps its other actions.
431
441
  - **Deep links to a claim** (`claim_link.js`). The owner selects text in a turn's answer, step or own message and
432
442
  Annotate's **Copy link** copies `<base>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>`: `base` the participants list's
433
443
  `link_base` (`ParticipantsAdmin#participant_base`: `web.public_url`'s origin, else the LAN address, else the Host),
@@ -454,13 +464,18 @@ fields: a field added to an event later reaches nobody until it is named there.
454
464
  with the submitted turn, without changing its prompt text. A Verify turn that fails fast and is rolled back hands
455
465
  the claim back with its prompt (`prompt_restored`'s `verify`), so the owner's resend from the composer is a Verify
456
466
  turn again. Before the Engine stamps the anchor or records a verdict, it checks that the source turn exists and
457
- its assistant reply contains the quote. Matching is case-sensitive, collapses whitespace, and strips common
467
+ its final assistant reply (`AnswerTail`: the turn's last assistant message with text) contains the quote. Matching is case-sensitive, collapses whitespace, and strips common
458
468
  Markdown presentation (links, headings, list/blockquote prefixes, emphasis and code delimiters) from both sides;
459
- this matches the rendered text the page selects without treating different casing as the same claim. A fresh child
469
+ this matches the rendered text the page selects without treating different casing as the same claim. The quote may
470
+ also be in the answer as the pages show it (`Engine#verify_quote_in?`, `ParticipantView::Redact.text`: the session's
471
+ folder relative, home `~`, secret shapes `[redacted]`): a participant's page has only that text, and the owner's
472
+ page quotes it the same way (`claimOpts()`, `normalizeClaim`; `spec/fixtures/redact_cases.json` keeps the two
473
+ sides alike), so a claim naming a path is taken on both. A fresh child
460
474
  checks the same anchor against its parent's history. A completed Verify reply whose final non-empty line has
461
475
  a valid `Verdict: confirmed|refuted|unclear — <reason>` appends a verdict to the session. Both pages place its card
462
- after the source turn, name its author, show the reason and quote, and link to the checking turn; a live completion
463
- adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
476
+ in the source turn, right after the bubble that holds the quote (the answer's, matched as a claim link reads the
477
+ page; a quote not found yet goes at the turn's end), name its author, show the reason and quote, and link to the
478
+ checking turn; a live completion adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
464
479
  quote and reason redacted by the view's normal rules, never the full reply.
465
480
  - **Verify fresh** (`VerifyFresh`, `verdict_fresh.js`). An `unclear` card has a **Verify fresh** button (the owner's
466
481
  page always; a participant's while `can_comment`, i.e. while their grant could run a turn now; never in the owner's
@@ -520,7 +535,9 @@ fields: a field added to an event later reaches nobody until it is named there.
520
535
  `:verdict_recorded`; chi web shows the ones still waiting (`VerifyFresh.verdicts_of`: the summary, the review
521
536
  data). The card shows "Verifying fresh…" while the child runs (the summary's `verify_fresh` `{of, child_id}`,
522
537
  the review data's `fresh_running`, ids only) and the fresh verdict as a `fresh: ✅ confirmed — …` line on the same
523
- card. The participant view passes `fresh` and `of`, never the child's id (a participant can't open it). No push
538
+ card. The original stays unclear, so the button stays, as **Verify fresh again**: each press is another paid
539
+ child (a participant's against their cap). The participant view passes `fresh` and `of`, never the child's id (a
540
+ participant can't open it). No push
524
541
  for a fresh verdict yet (`Attention` pushes a verdict at a turn's end).
525
542
  - **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
526
543
  `participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
@@ -232,8 +232,11 @@ is prefilled once. The native and the chat loop use the same plan.
232
232
  classifier: `Thinking.cache_cost` (`:full` for Gemma, `:provider` for a remote chat host, `:tail` otherwise) and
233
233
  `ReasoningHistory.cache_cost` (`:history` for a change to or from `all` once an earlier reply has thinking).
234
234
  `CacheConfirm.needed?` asks only for `:full`, `:provider` and `:history`, when the effective level changes and
235
- the session has a model reply. A new setting with a cost (a `/model` across templates, `/llm-context` turning
236
- forget on) adds its classifier and goes through the same step.
235
+ the session has a model reply. The confirmation estimates the reread size from the latest known context count:
236
+ the whole conversation for `:full` / `:provider`, or up to the first changed reply for `:history`; it omits the
237
+ estimate when no count is known. On `api: anthropic`, a `:full` / `:provider` change also warns that preserved
238
+ thinking in replies is dropped from then on. A new setting with a cost (a `/model` across templates,
239
+ `/llm-context` turning forget on) adds its classifier and goes through the same step.
237
240
  7. **No wall-clock time rendered at wire time.** A history entry's `at` (`MessageTime::KEY`, when it happened) is
238
241
  display-only: no wire format reads it, and a guard spec checks that `ChatLoop#wire_messages` and
239
242
  `Prompt.format_with_images` give the same bytes with and without it (`spec/message_time_history_spec.rb`). A
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.130.1
3
+ version: 0.131.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- # The confirm step before a session setting change that restarts the
5
- # prompt cache (/thinking, /reasoning-history): such a change, sent
6
- # without --yes from a person at a web tab or a chi TUI, changes nothing
4
+ # The confirm step before a command that restarts the prompt cache: sent
5
+ # without --yes by a person at a web tab or a chi TUI, it changes nothing
7
6
  # and replies with what it costs and the line that applies it. Two steps,
8
7
  # no stored state: the reply carries a Confirm (SessionCommands::Result,
9
8
  # the worker's command_ran), the TUI prefills its line, the web draws an
@@ -43,12 +42,33 @@ module Samagotchi
43
42
  changed && history && COSTLY.include?(cost)
44
43
  end
45
44
 
46
- # The Confirm for +name+ set to +word+: the line with --yes, and the
47
- # question naming it.
48
- # @return [Confirm]
49
- def confirm(name, word)
50
- line = "#{name} #{word} --yes"
51
- Confirm.new(line: line, text: "Nothing changed yet: send `#{line}` to apply.")
45
+ # Whether a cache-changing command should ask, and its reply line.
46
+ # Kept independent of any UI so commands and plugins can share it.
47
+ # @param line [String] the full command line without --yes
48
+ # @param cost [Symbol, nil] the change's cache cost
49
+ # @param changed [Boolean] whether the effective setting changes
50
+ # @param engine [Engine] supplies history, used tokens and model host
51
+ # @return [Confirm, nil]
52
+ def ask(line:, cost:, changed:, engine:)
53
+ return nil unless needed?(cost: cost, history: engine.history?, changed: changed)
54
+
55
+ apply_line = "#{line.to_s.strip} --yes"
56
+ notes = []
57
+ tokens = engine.cache_restart_tokens
58
+ if tokens
59
+ size = tokens >= 1000 ? "#{format("%.1f", tokens / 1000.0).delete_suffix(".0")}k" : tokens.to_s
60
+ notes << case cost
61
+ when :full, :provider
62
+ "The next request reads the whole conversation again (about #{size} tokens)."
63
+ when :history
64
+ "The next request reads again from the first changed reply (up to about #{size} tokens)."
65
+ end
66
+ end
67
+ if %i[full provider].include?(cost) && engine.cache_restart_anthropic?
68
+ notes << "On an `api: anthropic` host, preserved thinking in replies is dropped from then on."
69
+ end
70
+ notes << "Nothing changed yet: send `#{apply_line}` to apply."
71
+ Confirm.new(line: apply_line, text: notes.compact.join("\n"))
52
72
  end
53
73
  end
54
74
  end
@@ -16,6 +16,7 @@ require_relative "turn_note"
16
16
  require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
18
  require_relative "verify_anchor"
19
+ require_relative "web/participant_view"
19
20
  require_relative "verdict"
20
21
  require_relative "model_profile"
21
22
  require_relative "effective_model"
@@ -1352,6 +1353,19 @@ module Samagotchi
1352
1353
  Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
1353
1354
  end
1354
1355
 
1356
+ # The latest known context size used by a request, for a cache-restart
1357
+ # confirmation; nil before any request reports a count.
1358
+ # @return [Integer, nil]
1359
+ def cache_restart_tokens
1360
+ used = @metrics.snapshot.dig(:context, :used_tokens)
1361
+ used if used.is_a?(Integer)
1362
+ end
1363
+
1364
+ # Whether a cache restart can invalidate preserved thinking blocks.
1365
+ def cache_restart_anthropic?
1366
+ effective_model.entry&.api.to_s == "anthropic"
1367
+ end
1368
+
1355
1369
  # What a thinking level change costs the effective model's prompt
1356
1370
  # cache (Thinking.cache_cost): :full, :provider or :tail.
1357
1371
  # @return [Symbol]
@@ -1646,13 +1660,28 @@ module Samagotchi
1646
1660
  return reject_verify_anchor(anchor, :reply_missing) unless reply
1647
1661
 
1648
1662
  quote_text = verify_comparison_text(anchor.quote)
1649
- answer_text = verify_comparison_text(reply[:content] || reply["content"])
1650
- return reject_verify_anchor(anchor, :quote_missing) if quote_text.empty? || !answer_text.include?(quote_text)
1663
+ quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
1664
+ return reject_verify_anchor(anchor, :quote_missing) unless quoted
1651
1665
 
1652
1666
  anchor
1653
1667
  end
1654
1668
  private :verified_anchor
1655
1669
 
1670
+ # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
1671
+ # pages show it: a page quotes its own text, which reads the session's
1672
+ # folder relative, the home folder as ~ and a secret shape as
1673
+ # [redacted] (a participant's page gets it so, ParticipantView::Redact;
1674
+ # the owner's page rewrites its quote the same way, claim_link.js
1675
+ # normalizeClaim, so claim links match both pages). +source+ is the
1676
+ # session whose folder that is.
1677
+ def verify_quote_in?(quote_text, answer, source)
1678
+ return true if verify_comparison_text(answer).include?(quote_text)
1679
+
1680
+ viewer = Web::ParticipantView::Viewer.new(cwd: source.working_directory)
1681
+ verify_comparison_text(Web::ParticipantView::Redact.text(answer, viewer)).include?(quote_text)
1682
+ end
1683
+ private :verify_quote_in?
1684
+
1656
1685
  def verify_anchor_source_session(session, anchor)
1657
1686
  return session unless session.verify_fresh
1658
1687
 
@@ -81,9 +81,10 @@ module Samagotchi
81
81
  return nil unless before && would
82
82
 
83
83
  cost = spec.cache_cost.call(@engine, before.level, would.level)
84
- return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
84
+ step = CacheConfirm.ask(line: "#{spec.name} #{word}", cost: cost, changed: would.level != before.level,
85
+ engine: @engine)
86
+ return nil unless step
85
87
 
86
- step = CacheConfirm.confirm(spec.name, word)
87
88
  [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{spec.cost_notes[cost]}", step.text].join("\n"),
88
89
  false, step]
89
90
  end
@@ -59,7 +59,7 @@ module Samagotchi
59
59
  # /help's note per listed mid_turn (the web's command list says the same).
60
60
  MID_TURN_NOTES = { "anytime" => "mid-turn too", "depends" => "show: mid-turn too" }.freeze
61
61
 
62
- # The commands that ask before a change that restarts the prompt cache
62
+ # Commands that may ask before a change that restarts the prompt cache
63
63
  # (CacheConfirm): #run passes them its confirm flag.
64
64
  CONFIRMING = %i[thinking reasoning_history toolkit].freeze
65
65
 
@@ -287,8 +287,8 @@ module Samagotchi
287
287
  # How help and errors name an entry (the shell's "!" is "!<cmd>").
288
288
  def self.display_name(entry) = entry.name == SHELL_BANG_PREFIX ? "#{SHELL_BANG_PREFIX}<cmd>" : entry.name
289
289
 
290
- # @param confirm [Boolean] a costly /thinking or /reasoning-history
291
- # change asks first (CacheConfirm): true for a person at a web tab or
290
+ # @param confirm [Boolean] a costly setting change asks first
291
+ # (CacheConfirm): true for a person at a web tab or
292
292
  # a TUI; false applies it at once (chi send, a delegate, a plugin,
293
293
  # chi -p)
294
294
  # @param as_parent [Boolean] a parent agent sent it (client cli:answer):
@@ -304,7 +304,7 @@ module Samagotchi
304
304
  return nil unless entry&.handler
305
305
  return run_bundle_command(entry, text) unless entry.source == "core"
306
306
 
307
- # The two that may ask take the flag; the rest never ask.
307
+ # Only commands that may ask take the flag.
308
308
  return send(entry.id, text, confirm: confirm) if CONFIRMING.include?(entry.id)
309
309
  # Who sent it goes to its handler per call, never through an ivar:
310
310
  # an anytime command runs on another thread against this instance.
@@ -54,8 +54,8 @@ module Samagotchi
54
54
  end
55
55
 
56
56
  cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
57
- if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
58
- step = CacheConfirm.confirm(NAME, words)
57
+ step = CacheConfirm.ask(line: "#{NAME} #{words}", cost: cost, changed: true, engine: @engine) if confirm && !yes
58
+ if step
59
59
  return [[change_line(before, after), reread_line(before, after), step.text].join("\n"), false, step]
60
60
  end
61
61
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.130.1"
4
+ VERSION = "0.131.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -211,7 +211,8 @@ module Samagotchi
211
211
  # @return [Array<Hash>] {role: "user", content, turn_id, author,
212
212
  # author_name, mine, automatic, images, at} and {role: "assistant",
213
213
  # content, tools: [{tool, path}], at}; no notes, steers, automatic
214
- # input or empty-answer markers. html is added by the caller, from
214
+ # input or empty-answer markers, but a wake turn's start as an
215
+ # automatic turn (#wake_start). html is added by the caller, from
215
216
  # this (redacted) content.
216
217
  def messages(shown, viewer, automatic: [])
217
218
  Array(shown).filter_map do |message|
@@ -220,6 +221,7 @@ module Samagotchi
220
221
  out = case field(message, :role).to_s
221
222
  when "user" then user_message(message, viewer, automatic)
222
223
  when "assistant" then assistant_message(message, viewer)
224
+ when "note" then wake_start(message)
223
225
  end
224
226
  out && Redact.deep(out, viewer)
225
227
  end
@@ -228,15 +230,15 @@ module Samagotchi
228
230
  # A user message a participant sees: a turn's prompt (it has the
229
231
  # turn's id) or a person's line merged into a turn. Every other user
230
232
  # message is dropped: the owner's `!` line and its output, a
231
- # delegate's report (a wake turn's input), the notes chi adds (a
232
- # Continue the owner declined). Any message from a source that isn't a
233
- # person's is dropped whatever it is.
233
+ # delegate's report (a wake turn's input: only its turn's start is
234
+ # kept, #wake_start), the notes chi adds (a Continue the owner
235
+ # declined). Any other message from a source that isn't a person's is
236
+ # dropped whatever it is.
234
237
  def user_message(message, viewer, automatic = [])
235
238
  source = field(message, :source).to_s
236
- return nil unless PERSON_SOURCES.include?(source)
237
-
238
239
  turn_id = field(message, :turn_id)
239
240
  merged = field(message, :merged)
241
+ return (turn_id && !merged ? wake_start(message) : nil) unless PERSON_SOURCES.include?(source)
240
242
  return nil unless turn_id || merged
241
243
 
242
244
  out = { "role" => "user", **time_field(message) }
@@ -254,6 +256,20 @@ module Samagotchi
254
256
  out
255
257
  end
256
258
 
259
+ # A wake turn's start (its prompt a delegate's report or a context
260
+ # note, MessageDisplay's turn_start note): the turn's boundary only,
261
+ # as an automatic turn with no text, so its answer is never read as
262
+ # one more reply of the turn before (Verify, verdict cards, claim
263
+ # links group by these). Nothing of the report or the note. nil for
264
+ # a message that starts no turn.
265
+ def wake_start(message)
266
+ turn_id = field(message, :turn_id)
267
+ return nil if turn_id.to_s.empty?
268
+ return nil if field(message, :role).to_s == "note" && field(message, :turn_start) != true
269
+
270
+ { "role" => "user", **time_field(message), "content" => "", "automatic" => true, "turn_id" => turn_id.to_s }
271
+ end
272
+
257
273
  def assistant_message(message, viewer)
258
274
  parts = field(message, :parts)
259
275
  tools = parts.is_a?(Hash) ? Array(field(parts, :tools)) : []
@@ -33,6 +33,9 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
33
33
  // @param onQuote (text) runs with the quote (and a preset's note) to append
34
34
  // to the composer; the selection is cleared and the bar hidden first
35
35
  // @param disabledPreset (preset) → a reason to disable it, else null
36
+ // @param offeredPreset (preset, source, range) → whether the selection
37
+ // offers it (default: every preset); one not offered is hidden, not
38
+ // disabled (Verify outside a turn's final answer: verify_target.js)
36
39
  // @param actions () => the open session's annotation actions
37
40
  // (annotate_actions.js annotationActions), read on every selection
38
41
  // @param running () => whether a turn runs (a send pill may wait for it)
@@ -58,6 +61,7 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
58
61
  // next frame (call it on scroll)
59
62
  export function createAnnotateBar({
60
63
  presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, disabledPreset = () => null,
64
+ offeredPreset = () => true,
61
65
  actions = () => [], running = () => false, onAction = () => {},
62
66
  dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
63
67
  doc = globalThis.document, win = globalThis.window,
@@ -85,6 +89,7 @@ export function createAnnotateBar({
85
89
  const presetButtons = [...bar.children].slice(1, presets.length + 1);
86
90
  function markPresets() {
87
91
  presetButtons.forEach((btn, i) => {
92
+ btn.hidden = !!pendingSource && !offeredPreset(presets[i], pendingSource, pendingRange);
88
93
  const reason = disabledPreset(presets[i]);
89
94
  btn.disabled = !!reason;
90
95
  btn.title = reason || presetParts(presets[i]).text;
@@ -259,7 +264,7 @@ export function createAnnotateBar({
259
264
  return;
260
265
  }
261
266
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
262
- if (btn.disabled || (preset && disabledPreset(preset))) return;
267
+ if (btn.disabled || btn.hidden || (preset && disabledPreset(preset))) return;
263
268
  const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
264
269
  const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
265
270
  win.getSelection()?.removeAllRanges();
@@ -65,6 +65,7 @@ import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
65
65
  import { parsePresets } from "./annotate_presets.js";
66
66
  import { createAnnotateBar } from "./annotate_bar.js";
67
67
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
68
+ import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
68
69
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
69
70
  import { copyText, installCopy } from "./copy.js";
70
71
  import { createQuestionCards } from "./question_cards.js";
@@ -1351,6 +1352,8 @@ function placeVerdict(record) {
1351
1352
  placeVerdictWithFresh(historyEl, record, {
1352
1353
  who: badge ? badge.name : "You",
1353
1354
  author: record.by || null,
1355
+ roots: CLAIM_ROOTS,
1356
+ opts: claimOpts(),
1354
1357
  onMissingCheck: (missing) => {
1355
1358
  const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
1356
1359
  if (hash) location.hash = hash;
@@ -1959,8 +1962,9 @@ const updateSheet = createUpdateSheet({
1959
1962
  function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
1960
1963
 
1961
1964
  // Annotate (annotate_bar.js): a selection in the conversation offers
1962
- // "Annotate" and the presets (then the built-in Verify: verify_preset.js),
1963
- // which append a quote to the composer, the session's plugins' pills
1965
+ // "Annotate" and the presets (then the built-in Verify: verify_preset.js,
1966
+ // only in an ended turn's final answer: verify_target.js), which append a
1967
+ // quote to the composer, the session's plugins' pills
1964
1968
  // (chi.annotation, annotate_actions.js), which fill the composer with their
1965
1969
  // command line and the quote, or send it at once, and Copy link in a turn
1966
1970
  // (claimLinkFor).
@@ -1978,6 +1982,7 @@ annotateBar = createAnnotateBar({
1978
1982
  presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1979
1983
  disabledPreset: (preset) => preset === VERIFY_PRESET
1980
1984
  && verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
1985
+ offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
1981
1986
  canAnnotate: () => !!selected && current.owner !== "tui",
1982
1987
  roots: turnRoots,
1983
1988
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
@@ -1997,10 +2002,14 @@ annotateBar = createAnnotateBar({
1997
2002
  dock: () => $("#dock"),
1998
2003
  });
1999
2004
 
2005
+ // Verify's claim: a selection in an ended turn's final answer (the only
2006
+ // text Engine#verified_anchor takes; verify_target.js), else null and no
2007
+ // Verify on the bar.
2000
2008
  function verifyAnchorFor({ source, range }) {
2001
- if (!source || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
2002
- const turnId = turnIdOf(historyEl, source.root);
2003
- const quote = selectionClaim(source.root, range, claimOpts()).q;
2009
+ if (source?.kind !== "answer" || !range) return null;
2010
+ const running = liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null;
2011
+ const turnId = verifyTurnOf(historyEl, source.root, OWNER_FINAL, { running });
2012
+ const quote = turnId ? selectionClaim(source.root, range, claimOpts()).q : "";
2004
2013
  return turnId && quote ? { turn_id: turnId, quote } : null;
2005
2014
  }
2006
2015
  historyEl.addEventListener("scroll", annotateBar.schedule);
@@ -23,6 +23,7 @@ import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_f
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
25
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
26
+ import { PARTICIPANT_FINAL, verifyTurnOf } from "./verify_target.js";
26
27
  import { appendQuote } from "./annotations.js";
27
28
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
29
  import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
@@ -76,6 +77,8 @@ let drawnMessages = [];
76
77
  // A claim link's turn and quote (claim_link.js: #s=…&t=…&q=…), shown once
77
78
  // the thread is drawn; a later hash change shows another.
78
79
  let pendingClaim = claimFromHash(globalThis.location?.hash);
80
+ // Where a claim's text can be on this page (a claim link, a verdict's card).
81
+ const CLAIM_ROOTS = ".p-msg > .p-bubble";
79
82
  let claimNoteTimer = null;
80
83
 
81
84
  function el(tag, cls, text) {
@@ -259,7 +262,7 @@ function showPendingClaim() {
259
262
  return showClaimNote(`This link is for another session; this browser holds your link for ${title}.`);
260
263
  }
261
264
  setView("thread");
262
- if (showClaim($("thread"), claim, { roots: ".p-msg > .p-bubble" })) return;
265
+ if (showClaim($("thread"), claim, { roots: CLAIM_ROOTS })) return;
263
266
  $("main").scrollTop = 0;
264
267
  showClaimNote("The linked turn isn't in this session.");
265
268
  }
@@ -330,6 +333,8 @@ function setView(view) {
330
333
  $("segThread").setAttribute("aria-selected", String(shown === "thread"));
331
334
  }
332
335
 
336
+ const AUTOMATIC_TURN = "An automatic turn";
337
+
333
338
  function messageNode(message) {
334
339
  const user = message.role === "user";
335
340
  const node = el("div", `p-msg ${user ? "user" : "reply"}${message.mine ? " mine" : ""}`);
@@ -346,7 +351,10 @@ function messageNode(message) {
346
351
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
347
352
  if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
348
353
  node.append(who);
349
- const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
354
+ // An automatic turn (a task's, a wake turn's: a delegate report or a
355
+ // context note started it) opens its turn without its prompt.
356
+ const content = message.automatic && !message.content ? AUTOMATIC_TURN : message.content;
357
+ const text = content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
350
358
  node.append(el("div", "p-bubble", text));
351
359
  return node;
352
360
  }
@@ -423,6 +431,7 @@ function renderThread() {
423
431
  placeVerdictWithFresh(thread, record, {
424
432
  who,
425
433
  author,
434
+ roots: CLAIM_ROOTS,
426
435
  onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
427
436
  }, verifyFreshState());
428
437
  }
@@ -477,7 +486,7 @@ function currentTurnNodes() {
477
486
  const turn = data.current_turn;
478
487
  const nodes = [];
479
488
  if (turn.prompt !== undefined || turn.automatic) {
480
- nodes.push(messageNode({ role: "user", content: turn.automatic ? "An automatic turn" : turn.prompt, author: turn.author,
489
+ nodes.push(messageNode({ role: "user", content: turn.automatic ? AUTOMATIC_TURN : turn.prompt, author: turn.author,
481
490
  author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic, at: turn.at, turn_id: turn.turn_id }));
482
491
  }
483
492
  const reply = el("div", "p-msg reply live");
@@ -653,7 +662,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
653
662
 
654
663
  // Annotate (annotate_bar.js, participant_annotate.js): a selection in the
655
664
  // Plan or the thread offers "Annotate" and the presets
656
- // (web.annotate_presets, then the built-in Verify: verify_preset.js), which
665
+ // (web.annotate_presets, then the built-in Verify: verify_preset.js, only
666
+ // in an ended turn's final reply: verify_target.js), which
657
667
  // append a quote to the comment box; nothing is sent. No plugin pills: a
658
668
  // review turn refuses `/` lines. Not in the owner's participant view, nor
659
669
  // while this participant can't comment.
@@ -661,6 +671,7 @@ const annotateBar = createAnnotateBar({
661
671
  presets: withVerify(parsePresets(body.annotatePresets)),
662
672
  disabledPreset: (preset) => preset === VERIFY_PRESET
663
673
  && data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
674
+ offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
664
675
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
665
676
  roots: () => [$("planDoc"), $("thread")],
666
677
  isLive: (root) => !!root.closest?.(".p-msg.live"),
@@ -671,9 +682,16 @@ const annotateBar = createAnnotateBar({
671
682
  dock: () => $("dock"),
672
683
  });
673
684
 
685
+ // Verify's claim: a selection in an ended turn's final reply (the last
686
+ // reply bubble after the turn's opening, as the owner's page and
687
+ // placeVerdict count turns: verify_target.js), the only text
688
+ // Engine#verified_anchor takes; else null and no Verify on the bar. Not an
689
+ // earlier reply, a line merged mid-turn, a prompt, the running turn or the
690
+ // Plan.
674
691
  function verifyAnchorFor({ source, range }) {
675
- const message = source?.root?.closest?.(".p-msg");
676
- const turnId = message?.dataset?.turnId;
692
+ if (source?.kind !== "reply" || !range) return null;
693
+ const running = data?.current_turn ? { turnId: data.current_turn.turn_id || null } : null;
694
+ const turnId = verifyTurnOf($("thread"), source.root, PARTICIPANT_FINAL, { running });
677
695
  if (!turnId) return null;
678
696
  const quote = selectionClaim(source.root, range).q;
679
697
  return quote ? { turn_id: turnId, quote } : null;
@@ -1,4 +1,4 @@
1
- import { ownerClaimHash } from "./claim_link.js";
1
+ import { findClaim, ownerClaimHash } from "./claim_link.js";
2
2
 
3
3
  const TYPES = Object.freeze({
4
4
  confirmed: { icon: "✅", label: "confirmed" },
@@ -17,7 +17,14 @@ export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
17
17
  return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
18
18
  }
19
19
 
20
- export function placeVerdict(root, record, { who = "Owner", author = null, onMissingCheck = null } = {}) {
20
+ // A verdict's card on its source turn: after the bubble that holds the
21
+ // quote (+roots+, the page's claim roots selector; +opts+, how it reads the
22
+ // page's text, claim_link.js), the answer's first; a quote not found (a
23
+ // bubble not drawn yet): at the turn's end. Cards on one bubble keep their
24
+ // order; a card already on the page is returned as it is.
25
+ export function placeVerdict(root, record, {
26
+ who = "Owner", author = null, onMissingCheck = null, roots = null, opts = {},
27
+ } = {}) {
21
28
  const anchor = record?.anchor;
22
29
  const type = TYPES[record?.verdict];
23
30
  if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
@@ -67,11 +74,48 @@ export function placeVerdict(root, record, { who = "Owner", author = null, onMis
67
74
  });
68
75
  card.append(heading, quote, reason, link);
69
76
 
70
- const previous = cards.filter((candidate) => candidate.dataset.sourceTurnId === anchor.turn_id).at(-1);
71
- (previous || source).after(card);
77
+ afterCards(quotedElement(source, anchor.quote, roots, opts) || turnEnd(source)).after(card);
72
78
  return card;
73
79
  }
74
80
 
81
+ // The element at +source+'s level that holds the quote: of the turn's
82
+ // roots after its prompt, the last match first (the answer the Engine
83
+ // checked the quote against comes after the turn's steps).
84
+ function quotedElement(source, quote, selector, opts) {
85
+ if (!selector || !source.parentElement) return null;
86
+ const level = source.parentElement;
87
+ const candidates = turnElements(source).flatMap((element) =>
88
+ [...(element.matches(selector) ? [element] : []), ...element.querySelectorAll(selector)]).reverse();
89
+ const hit = findClaim(candidates.map((candidate) => candidate.textContent), { q: quote }, opts);
90
+ if (!hit) return null;
91
+ let element = candidates[hit.at];
92
+ while (element && element.parentElement !== level) element = element.parentElement;
93
+ return element || null;
94
+ }
95
+
96
+ // +source+'s turn: its siblings after it up to the next turn's opening
97
+ // (or a gap divider before it).
98
+ function turnElements(source) {
99
+ const elements = [];
100
+ for (let next = source.nextElementSibling; next; next = next.nextElementSibling) {
101
+ if (next.matches("[data-turn-id]") || next.querySelector("[data-turn-id]")) break;
102
+ elements.push(next);
103
+ }
104
+ while (elements.at(-1)?.classList.contains("time-gap")) elements.pop();
105
+ return elements;
106
+ }
107
+
108
+ function turnEnd(source) {
109
+ return turnElements(source).at(-1) || source;
110
+ }
111
+
112
+ // +element+, or the last of the verdict cards right after it.
113
+ function afterCards(element) {
114
+ let last = element;
115
+ while (last.nextElementSibling?.classList.contains("verify-verdict")) last = last.nextElementSibling;
116
+ return last;
117
+ }
118
+
75
119
  export function nameVerdictCards(root, nameFor) {
76
120
  for (const card of root?.querySelectorAll?.(".verify-verdict[data-author]") || []) {
77
121
  const name = nameFor(card.dataset.author);
@@ -51,7 +51,8 @@ export function redrawFresh(root, fresh = {}) {
51
51
 
52
52
  // The card's Verify fresh part, drawn again from +fresh+: the fresh
53
53
  // verdicts, the running check, else the button (an unclear verdict, and a
54
- // viewer who could run a turn now).
54
+ // viewer who could run a turn now; "Verify fresh again" after a fresh
55
+ // verdict, each press another paid check).
55
56
  export function renderFresh(card, fresh = {}) {
56
57
  const doc = card.ownerDocument;
57
58
  const of = card.dataset.checkTurnId;
@@ -68,7 +69,10 @@ export function renderFresh(card, fresh = {}) {
68
69
  return link;
69
70
  };
70
71
 
71
- for (const result of (fresh.verdicts || []).filter((r) => r?.fresh && r.of === of)) {
72
+ const results = (fresh.verdicts || []).filter((r) => r?.fresh && r.of === of);
73
+ // Once a fresh verdict is in, the button says a press runs another one.
74
+ const label = results.length ? "Verify fresh again" : "Verify fresh";
75
+ for (const result of results) {
72
76
  const line = doc.createElement("p");
73
77
  line.className = `verify-fresh-result ${ICONS[result.verdict] ? result.verdict : ""}`.trim();
74
78
  line.textContent = `fresh: ${ICONS[result.verdict] || ""} ${result.verdict} — ${result.reason || ""}`;
@@ -89,7 +93,7 @@ export function renderFresh(card, fresh = {}) {
89
93
  const button = doc.createElement("button");
90
94
  button.type = "button";
91
95
  button.className = "verify-fresh-btn";
92
- button.textContent = "Verify fresh";
96
+ button.textContent = label;
93
97
  button.title = fresh.unavailableReason;
94
98
  button.disabled = true;
95
99
  const note = doc.createElement("p");
@@ -100,7 +104,7 @@ export function renderFresh(card, fresh = {}) {
100
104
  const button = doc.createElement("button");
101
105
  button.type = "button";
102
106
  button.className = "verify-fresh-btn";
103
- button.textContent = "Verify fresh";
107
+ button.textContent = label;
104
108
  button.title = "A new agent checks this claim against the files, with none of this session's context";
105
109
  button.addEventListener("click", () => startFresh(card, button, fresh));
106
110
  part.append(button);
@@ -0,0 +1,73 @@
1
+ // Where the Verify preset is offered (verify_preset.js), on both pages: only
2
+ // on text in an ended turn's final answer, the one place the server takes a
3
+ // claim from. Engine#verified_anchor records a Verify turn's card only when
4
+ // its quote is in the last answer of the source turn (AnswerTail.answer?:
5
+ // the turn's last assistant message with text, from its prompt up to the
6
+ // next prompt, merged lines and steers inside); anything else (an earlier
7
+ // reply, a step, a person's line, a reply still running) is refused with
8
+ // `quote_missing` and the turn runs as a plain comment, no card. So the
9
+ // page offers Verify where the server will accept it, and nowhere else.
10
+ //
11
+ // The turn is the page's own grouping (claim_link.js turnIdOf,
12
+ // verdict_card.js turnElements): the last turn opening ([data-turn-id]) at
13
+ // or before the selection, and its elements up to the next opening. Its
14
+ // final answer is the last of them matching the page's answer selector.
15
+ //
16
+ // Pure DOM reads (Element, compareDocumentPosition), so it runs under
17
+ // `node --test` with happy-dom. A module of its own: static JS is cached at
18
+ // unversioned URLs, and a new export on an old module would blank the page.
19
+
20
+ const PRECEDING = 2;
21
+
22
+ // The owner's page: an answer bubble (not a delegate report's reply, which
23
+ // a turn shows before its answer); a context note ends a turn's elements
24
+ // (a wake turn starts with one, and its bubble carries no turn id).
25
+ export const OWNER_FINAL = Object.freeze({ answers: ".bubble.output:not(.report-reply)", boundary: ".bubble.note" });
26
+ // The participant page: a reply's bubble.
27
+ export const PARTICIPANT_FINAL = Object.freeze({ answers: ".p-msg.reply > .p-bubble", boundary: null });
28
+
29
+ // The turn opening +node+ is in (the last [data-turn-id] at or before it in
30
+ // +container+) and whether it is the newest one: {anchor, last}, or null.
31
+ function openingOf(container, node) {
32
+ const anchors = [...container.querySelectorAll("[data-turn-id]")];
33
+ let at = -1;
34
+ anchors.forEach((anchor, i) => {
35
+ if (anchor === node || anchor.contains(node) || (node.compareDocumentPosition(anchor) & PRECEDING)) at = i;
36
+ });
37
+ return at < 0 ? null : { anchor: anchors[at], last: at === anchors.length - 1 };
38
+ }
39
+
40
+ // The final answer of the turn whose opening is +anchor+: the last element
41
+ // matching +answers+ among the opening's siblings after it, up to the next
42
+ // opening or a +boundary+ element. null when it has none.
43
+ function finalAnswer(container, anchor, { answers, boundary }) {
44
+ let top = anchor;
45
+ while (top && top.parentElement !== container) top = top.parentElement;
46
+ if (!top) return null;
47
+ let final = null;
48
+ for (let el = top.nextElementSibling; el; el = el.nextElementSibling) {
49
+ if (el.matches("[data-turn-id]") || el.querySelector("[data-turn-id]")) break;
50
+ if (boundary && el.matches(boundary)) break;
51
+ const inside = [...el.querySelectorAll(answers)];
52
+ if (inside.length) final = inside[inside.length - 1];
53
+ else if (el.matches(answers)) final = el;
54
+ }
55
+ return final;
56
+ }
57
+
58
+ // The turn id Verify anchors to for a selection in +root+ (the annotate
59
+ // source's root) in +container+, or null where Verify isn't offered: not in
60
+ // a turn, not in its final answer, or in the running turn. +rule+:
61
+ // OWNER_FINAL or PARTICIPANT_FINAL. +running+: null when no turn runs, else
62
+ // {turnId} (the running turn's id; null when unknown, then the newest turn
63
+ // counts as the running one) — a running turn's answer isn't final yet.
64
+ export function verifyTurnOf(container, root, rule, { running = null } = {}) {
65
+ if (!container || !root || !container.contains(root)) return null;
66
+ const opening = openingOf(container, root);
67
+ if (!opening) return null;
68
+ const turnId = opening.anchor.dataset.turnId || null;
69
+ if (!turnId) return null;
70
+ if (running && (opening.last || (running.turnId && running.turnId === turnId))) return null;
71
+ const final = finalAnswer(container, opening.anchor, rule);
72
+ return final && (final === root || final.contains(root)) ? turnId : null;
73
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.130.1
4
+ version: 0.131.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -893,6 +893,7 @@ files:
893
893
  - lib/samagotchi/web/public/verdict_fresh.js
894
894
  - lib/samagotchi/web/public/verify_card.css
895
895
  - lib/samagotchi/web/public/verify_preset.js
896
+ - lib/samagotchi/web/public/verify_target.js
896
897
  - lib/samagotchi/web/public/working_set.js
897
898
  - lib/samagotchi/web/public/working_set_rail.js
898
899
  - lib/samagotchi/web/public/working_set_view.js