samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -22,8 +22,11 @@ import { createAuthLost, lostAnswer } from "./auth_lost.js";
22
22
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
+ import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
25
26
  import { appendQuote } from "./annotations.js";
26
27
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
+ import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
29
+ import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
27
30
  import { mountComposer } from "./composer_mount.js";
28
31
  import { PROMPT_LINE_PX } from "./composer_size.js";
29
32
  import {
@@ -44,6 +47,7 @@ const ownerView = body.viewer === "owner";
44
47
  const sessionId = body.session || new URLSearchParams(location.hash.slice(1)).get("s") || "";
45
48
 
46
49
  let data = null;
50
+ let pendingVerifyAnchor = null;
47
51
  let live = null;
48
52
  // A turn boundary came after the data drawn: live events wait for the
49
53
  // re-read (its stream replays them).
@@ -68,6 +72,10 @@ let wantedView = "thread";
68
72
  // a message's node while it is unchanged (its open tool list, a text
69
73
  // selection in it).
70
74
  let drawnMessages = [];
75
+ // A claim link's turn and quote (claim_link.js: #s=…&t=…&q=…), shown once
76
+ // the thread is drawn; a later hash change shows another.
77
+ let pendingClaim = claimFromHash(globalThis.location?.hash);
78
+ let claimNoteTimer = null;
71
79
 
72
80
  function el(tag, cls, text) {
73
81
  const node = document.createElement(tag);
@@ -141,6 +149,7 @@ async function load() {
141
149
  live = liveFrom(data.current_turn);
142
150
  liveStale = false;
143
151
  render();
152
+ showPendingClaim();
144
153
  // The live turn is the data's now: the events after its cursor come
145
154
  // again from there (the open stream may have delivered some of them to
146
155
  // the live turn just replaced). With no cursor (no worker) an open
@@ -169,6 +178,16 @@ function connect() {
169
178
  const onEvent = (event) => {
170
179
  let parsed;
171
180
  try { parsed = JSON.parse(event.data); } catch { return; }
181
+ if (parsed.verdict) {
182
+ const known = (data.verdicts || []).some((record) => record.check_turn_id === parsed.verdict.check_turn_id);
183
+ if (!known) {
184
+ data.verdicts = [...(data.verdicts || []), parsed.verdict];
185
+ // A fresh verdict ends its check's "verifying fresh".
186
+ data.fresh_running = settleRunning(data.fresh_running, parsed.verdict);
187
+ renderThread();
188
+ }
189
+ }
190
+ if (parsed.type === "verdict_recorded") return;
172
191
  if (reloads(parsed.type)) {
173
192
  if (endsLiveTurn(parsed.type)) liveStale = true;
174
193
  return scheduleReload();
@@ -183,7 +202,7 @@ function connect() {
183
202
  for (const type of ["generation_chunk", "generation_retrying", "tool_call_started", "tool_call_completed",
184
203
  "turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued", "input_merged",
185
204
  "question_requested", "question_answered", "question_cancelled", "continue_offered", "continue_resolved",
186
- "snapshot", "reset"]) {
205
+ "snapshot", "reset", "verdict_recorded"]) {
187
206
  source.addEventListener(type, onEvent);
188
207
  }
189
208
  source.onerror = () => {
@@ -195,9 +214,11 @@ function connect() {
195
214
  function render() {
196
215
  const viewer = data.viewer || {};
197
216
  const session = data.session || {};
198
- document.title = ownerView ? `chi · participant view · ${session.title}` : `chi · ${viewer.name}'s review`;
217
+ // A pair participant works in the session: their page says so, not "review".
218
+ const whose = viewer.mode === "pair" ? `${viewer.name} · pairing` : `${viewer.name}'s review`;
219
+ document.title = ownerView ? `chi · participant view · ${session.title}` : `chi · ${whose}`;
199
220
  const title = $("title");
200
- title.replaceChildren(ownerView ? "Participant view" : `${viewer.name}'s review`, el("span", "p-sub", ` · ${session.title}`));
221
+ title.replaceChildren(ownerView ? "Participant view" : whose, el("span", "p-sub", ` · ${session.title}`));
201
222
  title.title = session.title || "";
202
223
  const status = $("status");
203
224
  const waiting = data.waiting_for_owner;
@@ -220,6 +241,40 @@ function render() {
220
241
  renderDock();
221
242
  }
222
243
 
244
+ // The claim link's quote in the thread, scrolled to and marked; a quote
245
+ // not found marks its turn; neither found (before a continue) leaves the
246
+ // thread at the top and says so, as does a link to another session (this
247
+ // browser's cookie is one grant's). The hash then drops the claim, so the
248
+ // same link opened again shows it again (a hashchange).
249
+ function showPendingClaim() {
250
+ const claim = pendingClaim;
251
+ if (!claim || !data) return;
252
+ pendingClaim = null;
253
+ const { pathname = "", search = "" } = globalThis.location || {};
254
+ globalThis.history?.replaceState?.(null, "", `${pathname}${search}#s=${encodeURIComponent(sessionId)}`);
255
+ if (claim.sessionId !== sessionId) {
256
+ const title = data.session?.title ? `"${data.session.title}"` : "this session";
257
+ return showClaimNote(`This link is for another session; this browser holds your link for ${title}.`);
258
+ }
259
+ setView("thread");
260
+ if (showClaim($("thread"), claim, { roots: ".p-msg > .p-bubble" })) return;
261
+ $("main").scrollTop = 0;
262
+ showClaimNote("The linked turn isn't in this session.");
263
+ }
264
+
265
+ function showClaimNote(text) {
266
+ const note = $("claimNote");
267
+ note.textContent = text;
268
+ note.hidden = false;
269
+ clearTimeout(claimNoteTimer);
270
+ claimNoteTimer = setTimeout(() => { note.hidden = true; }, 6000);
271
+ }
272
+
273
+ window.addEventListener("hashchange", () => {
274
+ pendingClaim = claimFromHash(globalThis.location?.hash);
275
+ showPendingClaim();
276
+ });
277
+
223
278
  // The review's subjects: beside the thread on a wide screen, behind the
224
279
  // Plan | Thread toggle on a narrow one; drawn again only when they changed.
225
280
  function renderPlan() {
@@ -353,10 +408,42 @@ function renderThread() {
353
408
  const main = $("main");
354
409
  const atBottom = main.scrollHeight - main.scrollTop - main.clientHeight < 80;
355
410
  syncChildren(thread, nodes);
411
+ for (const record of data.verdicts || []) {
412
+ const author = record.by || null;
413
+ const viewer = data.viewer || {};
414
+ let who = ownerView ? "You" : "Owner";
415
+ if (author && author !== "owner") {
416
+ if (!ownerView && author === participantAuthor(viewer.id)) who = "You";
417
+ else {
418
+ who = (data.participants || []).find((participant) => participantAuthor(participant.id) === author)?.name || "A participant";
419
+ }
420
+ }
421
+ placeVerdictWithFresh(thread, record, { who, author }, verifyFreshState());
422
+ }
356
423
  if (atBottom || !thread.dataset.drawn) main.scrollTop = main.scrollHeight;
357
424
  thread.dataset.drawn = "1";
358
425
  }
359
426
 
427
+ // Verify fresh on an unclear verdict's card (verdict_fresh.js): a
428
+ // participant starts one while their grant could run a turn now
429
+ // (can_comment); the owner looking through the participant view never
430
+ // does, and no one here gets a link to the child's session.
431
+ function verifyFreshState() {
432
+ if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
433
+ return {
434
+ verdicts: data.verdicts || [],
435
+ running: data.fresh_running,
436
+ canStart: !ownerView && !ended && !!data.viewer?.can_comment,
437
+ start: async (of) => {
438
+ const { ok, body } = await api(freshURL(sessionId, of), {
439
+ method: "POST", body: "{}", headers: { "Content-Type": "application/json" },
440
+ });
441
+ return ok ? { ok: true } : { ok: false, error: body?.detail || "Couldn't start a fresh check." };
442
+ },
443
+ childHref: () => null,
444
+ };
445
+ }
446
+
360
447
  // +node+'s tool list open or closed as +old+'s was (the same place drawn
361
448
  // again with new content).
362
449
  function keepToolsOpen(node, old) {
@@ -475,13 +562,15 @@ async function sendComment() {
475
562
  const comment = $("comment");
476
563
  const prompt = comment.value.trim();
477
564
  if (!prompt) return;
565
+ const verify = pendingVerifyAnchor;
478
566
  sending = true;
479
567
  $("send").disabled = true;
480
568
  showError("");
481
569
  let reply;
482
570
  try {
483
571
  reply = await api(`/api/sessions/${encodeURIComponent(sessionId)}/turn`, {
484
- method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt }),
572
+ method: "POST", headers: { "Content-Type": "application/json" },
573
+ body: JSON.stringify({ prompt, ...(verify ? { verify } : {}) }),
485
574
  });
486
575
  } finally {
487
576
  sending = false;
@@ -490,6 +579,7 @@ async function sendComment() {
490
579
  const { ok, status, body: json } = reply;
491
580
  if (ended) return;
492
581
  if (ok) {
582
+ if (pendingVerifyAnchor === verify) pendingVerifyAnchor = null;
493
583
  recordReviewComment(data?.viewer?.id, prompt);
494
584
  // What was typed while the comment was in flight stays in the box.
495
585
  if (comment.value.trim() === prompt) comment.value = "";
@@ -534,7 +624,11 @@ const composer = ownerView ? null : mountComposer({
534
624
  });
535
625
  if (composer) {
536
626
  // An error under the box (a refusal, the images toast) goes at the next edit.
537
- $("comment").addEventListener("input", () => showError(""));
627
+ // An emptied box lets go of its Verify claim.
628
+ $("comment").addEventListener("input", (e) => {
629
+ showError("");
630
+ if (!e.target.value.trim()) pendingVerifyAnchor = null;
631
+ });
538
632
  // The session composer's layout (composer.css), and the fold saved in this browser.
539
633
  document.body.classList.add("has-session");
540
634
  composer.newVisit();
@@ -549,19 +643,29 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
549
643
 
550
644
  // Annotate (annotate_bar.js, participant_annotate.js): a selection in the
551
645
  // Plan or the thread offers "Annotate" and the presets
552
- // (web.annotate_presets), which append a quote to the comment box; nothing
553
- // is sent. No plugin pills: a review turn refuses `/` lines. Not in the
554
- // owner's participant view, nor while this participant can't comment.
646
+ // (web.annotate_presets, then the built-in Verify: verify_preset.js), which
647
+ // append a quote to the comment box; nothing is sent. No plugin pills: a
648
+ // review turn refuses `/` lines. Not in the owner's participant view, nor
649
+ // while this participant can't comment.
555
650
  const annotateBar = createAnnotateBar({
556
- presets: parsePresets(body.annotatePresets),
651
+ presets: withVerify(parsePresets(body.annotatePresets)),
557
652
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
558
653
  roots: () => [$("planDoc"), $("thread")],
559
654
  isLive: (root) => !!root.closest?.(".p-msg.live"),
560
655
  onQuote: quoteIntoComment,
656
+ onPreset: (preset, fields) => { pendingVerifyAnchor = preset === VERIFY_PRESET ? verifyAnchorFor(fields) : null; },
561
657
  source: participantSource,
562
658
  quote: participantQuote,
563
659
  dock: () => $("dock"),
564
660
  });
661
+
662
+ function verifyAnchorFor({ source, range }) {
663
+ const message = source?.root?.closest?.(".p-msg");
664
+ const turnId = message?.dataset?.turnId;
665
+ if (!turnId) return null;
666
+ const quote = selectionClaim(source.root, range).q;
667
+ return quote ? { turn_id: turnId, quote } : null;
668
+ }
565
669
  $("main").addEventListener("scroll", annotateBar.schedule);
566
670
  $("planScroll").addEventListener("scroll", annotateBar.schedule);
567
671
 
@@ -247,6 +247,9 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
247
247
  // The header chi web's proxy names the user in (GET …/participants), null
248
248
  // without one: the Invite's email field.
249
249
  let identityHeader = null;
250
+ // Where a participant's link opens, no token (GET …/participants
251
+ // link_base): a claim link's base (claimTarget).
252
+ let linkBase = null;
250
253
  let loadedFor = null;
251
254
  let invite = null;
252
255
  let error = null;
@@ -289,6 +292,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
289
292
  grants = null;
290
293
  working = null;
291
294
  inviteMode = null;
295
+ linkBase = null;
292
296
  loadedFor = null;
293
297
  setParticipantNames([]);
294
298
  onChange();
@@ -301,6 +305,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
301
305
  working = typeof body.warning === "string" ? body.warning : null;
302
306
  inviteMode = typeof body.mode === "string" ? body.mode : null;
303
307
  identityHeader = typeof body.identity_header === "string" && body.identity_header ? body.identity_header : null;
308
+ linkBase = typeof body.link_base === "string" && body.link_base ? body.link_base : null;
304
309
  loadedFor = id;
305
310
  if (setParticipantNames(grants)) onNames();
306
311
  } catch {
@@ -310,6 +315,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
310
315
  grants = [];
311
316
  working = null;
312
317
  inviteMode = null;
318
+ linkBase = null;
313
319
  setParticipantNames([]);
314
320
  }
315
321
  }
@@ -446,6 +452,15 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
446
452
  // A participants_changed event, or a session's own refresh.
447
453
  reload: () => load(),
448
454
  chip: () => (session() && loadedFor === session() ? chipModel(grants, clock()) : null),
455
+ // A claim link's target (claim_link.js): {base, names} the open
456
+ // session's participant link base and its active people's names (an
457
+ // agent has no browser to open it in); null without either (an older
458
+ // chi web, no person invited).
459
+ claimTarget: () => {
460
+ if (!session() || loadedFor !== session() || !linkBase) return null;
461
+ const names = (grants || []).filter((g) => g.state === "active" && g.kind === "human").map((g) => g.name);
462
+ return names.length ? { base: linkBase, names } : null;
463
+ },
449
464
  close: () => shell.close(),
450
465
  isOpen: () => shell.isOpen(),
451
466
  };
@@ -1,5 +1,6 @@
1
1
  // The page's place in its URL hash: #/s/<session id> for an open session,
2
- // #/sessions for the all-sessions view, nothing for the empty start.
2
+ // #/sessions for the all-sessions view, nothing for the empty start. A
3
+ // session's hash may carry a claim link's ?t=…&q=… (claim_link.js).
3
4
  export const ALL_SESSIONS_HASH = "#/sessions";
4
5
 
5
6
  export function sessionHash(id) {
@@ -7,7 +8,7 @@ export function sessionHash(id) {
7
8
  }
8
9
 
9
10
  export function sessionIdFromHash(hash) {
10
- const m = /^#\/s\/([^/?#]+)$/.exec(hash || "");
11
+ const m = /^#\/s\/([^/?#]+)(?:\?[^#]*)?$/.exec(hash || "");
11
12
  if (!m) return null;
12
13
  try {
13
14
  return decodeURIComponent(m[1]) || null;
@@ -29,6 +29,7 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
29
29
  // +alert+: the browser's.
30
30
  export function createSendTurn({
31
31
  session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
32
+ verify = () => null, clearVerify = () => {},
32
33
  historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
33
34
  addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
34
35
  leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
@@ -131,7 +132,10 @@ export function createSendTurn({
131
132
  const sentTo = session();
132
133
  try {
133
134
  await uploadChips(sending, sentTo, { upload: api.uploadImage });
134
- const ack = await api.sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
135
+ const verifyAnchor = verify();
136
+ const ack = await api.sendTurn(sentTo, prompt, {
137
+ clientId, images: turnRefs(sending), history: typed, delivery, ...(verifyAnchor ? { verify: verifyAnchor } : {}),
138
+ });
135
139
  if (ack?.command_id) {
136
140
  // The worker knew it as a command (its list was newer than ours):
137
141
  // its command_ran shows it, not a prompt bubble.
@@ -140,6 +144,7 @@ export function createSendTurn({
140
144
  if (sentTo === session()) followWokenWorker();
141
145
  return;
142
146
  }
147
+ if (verifyAnchor) clearVerify(verifyAnchor);
143
148
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
144
149
  // A cut the worker didn't take (no thinking-only step: cut off) goes in
145
150
  // at the next step; a queue whose turn ended meanwhile is the next turn.
@@ -25,6 +25,9 @@ export function emptySessionState() {
25
25
  continues: null,
26
26
  // A review session's {subjects, from} (its `review`), or null.
27
27
  review: null,
28
+ verdicts: [],
29
+ // The Verify fresh checks running ({of, child_id}; the summary's verify_fresh).
30
+ verifyFresh: [],
28
31
  // Its start (the summary's created_at; the info bar's chain links are
29
32
  // times on its day), or null.
30
33
  createdAt: null,
@@ -116,6 +119,8 @@ export function sessionStateFrom(data) {
116
119
  state.parentId = s.parent_id || null;
117
120
  state.continues = s.continues || null;
118
121
  state.review = s.review || null;
122
+ state.verdicts = Array.isArray(s.verdicts) ? s.verdicts : [];
123
+ state.verifyFresh = Array.isArray(s.verify_fresh) ? s.verify_fresh : [];
119
124
  state.createdAt = s.created_at || null;
120
125
  state.commands = Array.isArray(data.commands) ? data.commands : [];
121
126
  return state;
@@ -181,6 +181,7 @@ export function createStreamHandlers(deps) {
181
181
  endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
182
182
  const out = turnOutput(data);
183
183
  if (out) appendChunk(out);
184
+ if (data.verdict) deps.placeVerdict?.(data.verdict);
184
185
  // after_turn hooks are running and may present the answer: its
185
186
  // answer_display (display: null when they didn't) comes next. A worker
186
187
  // that dies before it never sends one: workerGone releases the wait, and
@@ -356,6 +357,8 @@ export function createStreamHandlers(deps) {
356
357
  // Someone joined, left or was revoked (the worker's ParticipantsWatch):
357
358
  // the owner's chip and panel read the list again.
358
359
  participants_changed: () => participantsControl?.reload(),
360
+ // A Verify fresh child's verdict, taken onto its card by the worker.
361
+ verdict_recorded: (data) => deps.placeVerdict?.(data.verdict),
359
362
  continue_offered: (data) => questionCards.renderContinue(data),
360
363
  continue_resolved: (data) => questionCards.resolveContinue(data),
361
364
  tool_call_started: (data) => turnDom.toolStarted(data),
@@ -3,7 +3,8 @@
3
3
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
4
4
  --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
5
5
  --code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
6
- --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint)}
6
+ --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint);
7
+ --claim-mark:color-mix(in srgb,var(--warn-bright) 32%,transparent);--claim-ring:var(--warn)}
7
8
  /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
8
9
  @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0066cc;--accent-strong:#0058b0;--accent-bg:#e3f0ff;--accent-ring:rgba(0,102,204,.2);--primary:#0071e3;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b3d4f7;--pick-border:#0969da;
9
10
  --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
@@ -0,0 +1,65 @@
1
+ const TYPES = Object.freeze({
2
+ confirmed: { icon: "✅", label: "confirmed" },
3
+ refuted: { icon: "❌", label: "refuted" },
4
+ unclear: { icon: "❓", label: "unclear" },
5
+ });
6
+
7
+ export function placeVerdict(root, record, { who = "Owner", author = null } = {}) {
8
+ const anchor = record?.anchor;
9
+ const type = TYPES[record?.verdict];
10
+ if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
11
+ || typeof record.reason !== "string" || typeof record.check_turn_id !== "string") return null;
12
+
13
+ const elements = [...root.querySelectorAll("[data-turn-id]")];
14
+ const source = elements.find((element) => element.dataset.turnId === anchor.turn_id);
15
+ if (!source) return null;
16
+ const cards = [...root.querySelectorAll(".verify-verdict")];
17
+ const existing = cards.find((card) => card.dataset.checkTurnId === record.check_turn_id);
18
+ if (existing) return existing;
19
+
20
+ const doc = root.ownerDocument;
21
+ const card = doc.createElement("aside");
22
+ card.className = `verify-verdict ${record.verdict}`;
23
+ card.dataset.sourceTurnId = anchor.turn_id;
24
+ card.dataset.checkTurnId = record.check_turn_id;
25
+ if (author) card.dataset.author = author;
26
+ card.setAttribute("aria-label", `Claim verdict: ${type.label}`);
27
+
28
+ const heading = doc.createElement("div");
29
+ heading.className = "verify-verdict-heading";
30
+ heading.append(doc.createTextNode(`${type.icon} ${type.label} · `));
31
+ const name = doc.createElement("span");
32
+ name.className = "verify-verdict-who";
33
+ name.textContent = who;
34
+ heading.append(name);
35
+
36
+ const quote = doc.createElement("blockquote");
37
+ quote.className = "verify-verdict-quote";
38
+ quote.textContent = anchor.quote;
39
+ const reason = doc.createElement("p");
40
+ reason.className = "verify-verdict-reason";
41
+ reason.textContent = record.reason;
42
+
43
+ // Verify fresh (an unclear verdict's button, its fresh verdicts): verdict_fresh.js.
44
+ const link = doc.createElement("a");
45
+ link.href = "#";
46
+ link.className = "verify-verdict-link";
47
+ link.textContent = "View check";
48
+ link.addEventListener("click", (event) => {
49
+ event.preventDefault();
50
+ [...root.querySelectorAll("[data-turn-id]")].find((element) => element.dataset.turnId === record.check_turn_id)
51
+ ?.scrollIntoView?.({ behavior: "smooth", block: "center" });
52
+ });
53
+ card.append(heading, quote, reason, link);
54
+
55
+ const previous = cards.filter((candidate) => candidate.dataset.sourceTurnId === anchor.turn_id).at(-1);
56
+ (previous || source).after(card);
57
+ return card;
58
+ }
59
+
60
+ export function nameVerdictCards(root, nameFor) {
61
+ for (const card of root?.querySelectorAll?.(".verify-verdict[data-author]") || []) {
62
+ const name = nameFor(card.dataset.author);
63
+ if (name) card.querySelector(".verify-verdict-who").textContent = name;
64
+ }
65
+ }
@@ -0,0 +1,120 @@
1
+ // Verify fresh on a verdict card, both pages (docs/internals/participants.md,
2
+ // Verify fresh): an unclear verdict's card offers a new agent that checks the
3
+ // claim with none of the session's context; while it runs the card says so
4
+ // (the owner's links to its session), and its verdict lands on the same card
5
+ // as a "fresh:" line. A module of its own: static JS is cached at unversioned
6
+ // URLs, and a new export on an old module would blank the page.
7
+
8
+ import { placeVerdict } from "./verdict_card.js";
9
+
10
+ const ICONS = Object.freeze({ confirmed: "✅", refuted: "❌", unclear: "❓" });
11
+
12
+ // The request that starts one (owner and participant alike).
13
+ export function freshURL(sessionId, checkTurnId) {
14
+ return `/api/sessions/${encodeURIComponent(sessionId)}/verdicts/${encodeURIComponent(checkTurnId)}/fresh`;
15
+ }
16
+
17
+ // A running entry's verdict: the participant's are its check_turn_id, the
18
+ // owner's {of, child_id}.
19
+ function runningOf(entry) {
20
+ return typeof entry === "string" ? entry : entry?.of;
21
+ }
22
+
23
+ // +running+ without the check a fresh verdict just ended (+record+).
24
+ export function settleRunning(running, record) {
25
+ if (!record?.fresh) return running || [];
26
+ return (running || []).filter((entry) => runningOf(entry) !== record.of);
27
+ }
28
+
29
+ // Places a session verdict as the pages draw them: an original verdict's
30
+ // card (verdict_card.js) with its Verify fresh part, or a fresh verdict on
31
+ // its original's card. +fresh+: {verdicts (all of the session's), running
32
+ // (the page's own array; a start adds to it), canStart, start(of) → Promise
33
+ // of {ok, child_id, error}, childHref(id) → a link to the child or null}.
34
+ export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
35
+ if (record?.fresh) {
36
+ const card = [...(root?.querySelectorAll?.(".verify-verdict") || [])]
37
+ .find((candidate) => candidate.dataset.checkTurnId === record.of);
38
+ if (card) renderFresh(card, fresh);
39
+ return card || null;
40
+ }
41
+ const card = placeVerdict(root, record, opts);
42
+ if (card) renderFresh(card, fresh);
43
+ return card;
44
+ }
45
+
46
+ // The card's Verify fresh part, drawn again from +fresh+: the fresh
47
+ // verdicts, the running check, else the button (an unclear verdict, and a
48
+ // viewer who could run a turn now).
49
+ export function renderFresh(card, fresh = {}) {
50
+ const doc = card.ownerDocument;
51
+ const of = card.dataset.checkTurnId;
52
+ card.querySelector(":scope > .verify-fresh")?.remove();
53
+ const part = doc.createElement("div");
54
+ part.className = "verify-fresh";
55
+ const childLink = (id) => {
56
+ const href = id && fresh.childHref ? fresh.childHref(id) : null;
57
+ if (!href) return null;
58
+ const link = doc.createElement("a");
59
+ link.className = "verify-fresh-link";
60
+ link.href = href;
61
+ link.textContent = "open its session";
62
+ return link;
63
+ };
64
+
65
+ for (const result of (fresh.verdicts || []).filter((r) => r?.fresh && r.of === of)) {
66
+ const line = doc.createElement("p");
67
+ line.className = `verify-fresh-result ${ICONS[result.verdict] ? result.verdict : ""}`.trim();
68
+ line.textContent = `fresh: ${ICONS[result.verdict] || ""} ${result.verdict} — ${result.reason || ""}`;
69
+ const link = childLink(result.child_id);
70
+ if (link) line.append(" · ", link);
71
+ part.append(line);
72
+ }
73
+
74
+ const run = (fresh.running || []).find((entry) => runningOf(entry) === of);
75
+ if (run) {
76
+ const line = doc.createElement("p");
77
+ line.className = "verify-fresh-running";
78
+ line.textContent = "Verifying fresh…";
79
+ const link = childLink(typeof run === "string" ? null : run.child_id);
80
+ if (link) line.append(" ", link);
81
+ part.append(line);
82
+ } else if (card.classList.contains("unclear") && fresh.canStart && fresh.start) {
83
+ const button = doc.createElement("button");
84
+ button.type = "button";
85
+ button.className = "verify-fresh-btn";
86
+ button.textContent = "Verify fresh";
87
+ button.title = "A new agent checks this claim against the files, with none of this session's context";
88
+ button.addEventListener("click", () => startFresh(card, button, fresh));
89
+ part.append(button);
90
+ }
91
+
92
+ if (card.dataset.freshError) {
93
+ const line = doc.createElement("p");
94
+ line.className = "verify-fresh-error";
95
+ line.textContent = card.dataset.freshError;
96
+ part.append(line);
97
+ }
98
+ if (part.childNodes.length) card.append(part);
99
+ }
100
+
101
+ async function startFresh(card, button, fresh) {
102
+ const of = card.dataset.checkTurnId;
103
+ button.disabled = true;
104
+ button.textContent = "Starting…";
105
+ let result;
106
+ try {
107
+ result = await fresh.start(of);
108
+ } catch (error) {
109
+ result = { ok: false, error: error?.message };
110
+ }
111
+ if (result?.ok) {
112
+ delete card.dataset.freshError;
113
+ if (fresh.running && !fresh.running.some((entry) => runningOf(entry) === of)) {
114
+ fresh.running.push(result.child_id ? { of, child_id: result.child_id } : of);
115
+ }
116
+ } else {
117
+ card.dataset.freshError = result?.error || "Couldn't start a fresh check.";
118
+ }
119
+ renderFresh(card, fresh);
120
+ }
@@ -0,0 +1,17 @@
1
+ .verify-verdict{margin:8px 0 8px 14px;padding:8px 10px;border:1px solid var(--border-strong);border-left:3px solid var(--text-3);border-radius:8px;background:var(--bg-inset);font-size:13px}
2
+ .verify-verdict.confirmed{border-left-color:var(--ok);background:var(--ok-bg)}
3
+ .verify-verdict.refuted{border-left-color:var(--danger);background:var(--danger-bg-dim)}
4
+ .verify-verdict.unclear{border-left-color:var(--warn);background:var(--warn-bg)}
5
+ .verify-verdict-heading{font-weight:600;color:var(--text-bright)}
6
+ .verify-verdict-quote{margin:6px 0;color:var(--text-muted);font-style:italic;white-space:pre-wrap}
7
+ .verify-verdict-reason{margin:4px 0 6px;white-space:pre-wrap;overflow-wrap:anywhere}
8
+ .verify-verdict-link{color:var(--accent)}
9
+ .verify-fresh{margin-top:6px;padding-top:6px;border-top:1px solid var(--border)}
10
+ .verify-fresh p{margin:2px 0;white-space:pre-wrap;overflow-wrap:anywhere}
11
+ .verify-fresh-result{font-weight:600;color:var(--text-bright)}
12
+ .verify-fresh-running{color:var(--text-muted);font-style:italic}
13
+ .verify-fresh-error{color:var(--danger-text)}
14
+ .verify-fresh-link{color:var(--accent);font-weight:400}
15
+ .verify-fresh-btn{font:inherit;font-size:12px;font-weight:600;padding:3px 12px;border-radius:8px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
16
+ .verify-fresh-btn:hover{border-color:var(--accent)}
17
+ .verify-fresh-btn:disabled{opacity:.6;cursor:default}
@@ -0,0 +1,18 @@
1
+ // The built-in Verify preset, after web.annotate_presets' on both pages
2
+ // (docs/internals/participants.md, Deep links): the quote, then the ask to
3
+ // check it against the code and end on a verdict line a later card can
4
+ // read. Like a preset of the config's, it fills the box and never sends.
5
+ // A preset is a text (its label) or {label, text} (annotate_bar.js). A
6
+ // module of its own: static JS is cached at unversioned URLs, and a new
7
+ // export on an old module would blank the page.
8
+
9
+ export const VERIFY_PRESET = Object.freeze({
10
+ label: "Verify",
11
+ text: "Verify this claim against the code. If this session's context doesn't settle it, say so. "
12
+ + "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`.",
13
+ });
14
+
15
+ // +presets+ (annotate_presets.js parsePresets) with Verify after them.
16
+ export function withVerify(presets) {
17
+ return [...(presets || []), VERIFY_PRESET];
18
+ }
@@ -49,7 +49,7 @@ module Samagotchi
49
49
 
50
50
  # What +nxt+ newly needs the user for, compared with +prev+ (nil for
51
51
  # a session not seen yet): a new question first, then a new card,
52
- # then a finished turn (notify.js attentionFor's order).
52
+ # then a finished turn or its new Verify verdict (notify.js's order).
53
53
  # @return [Event, nil]
54
54
  def for(prev, nxt)
55
55
  return nil unless nxt && nxt[:id]
@@ -70,14 +70,20 @@ module Samagotchi
70
70
  turn_event(prev, nxt)
71
71
  end
72
72
 
73
- # A top-level turn that ended since +prev+: failed, or done when it
74
- # ran long or nobody watched it. A delegated child's turn is
75
- # reported by its parent's.
73
+ # A top-level turn that ended since +prev+: its new Verify verdict,
74
+ # failed, or done when it ran long or nobody watched it. A delegated
75
+ # child's turn is reported by its parent's.
76
76
  def turn_event(prev, nxt)
77
77
  turn = nxt[:last_turn]
78
78
  return nil unless turn.is_a?(Hash) && turn["ended_at"]
79
79
  return nil if turn["ended_at"] == prev&.dig(:last_turn, "ended_at") || nxt[:parent_id]
80
80
 
81
+ verdict = nxt[:last_verdict]
82
+ if verdict.is_a?(Hash) && present?(verdict[:check_turn_id]) &&
83
+ verdict[:check_turn_id] != prev&.dig(:last_verdict, :check_turn_id)
84
+ return Event.new(reason: "verdict", session_id: nxt[:id], key: key(nxt[:id], verdict[:check_turn_id]), hold: nil)
85
+ end
86
+
81
87
  reason = case turn["outcome"]
82
88
  when "failed" then "failed"
83
89
  when "completed"
@@ -133,10 +139,24 @@ module Samagotchi
133
139
  name = (summary && [summary[:title], summary[:auto_title], summary[:recap], summary[:first_preview], summary[:last_prompt]]
134
140
  .find { |v| present?(v) }) ||
135
141
  "chi session"
136
- body = reason == "done" ? "done in #{seconds(summary&.dig(:last_turn) || {}).round} s" : BODIES.fetch(reason, "")
142
+ body = case reason
143
+ when "done" then "done in #{seconds(summary&.dig(:last_turn) || {}).round} s"
144
+ when "verdict" then verdict_text(summary&.dig(:last_verdict) || {})
145
+ else BODIES.fetch(reason, "")
146
+ end
137
147
  [clip(name.to_s.strip, TITLE_MAX), body]
138
148
  end
139
149
 
150
+ def verdict_text(verdict)
151
+ result = verdict[:verdict].to_s
152
+ icon = { "confirmed" => "✅", "refuted" => "❌", "unclear" => "❓" }.fetch(result, "")
153
+ by = verdict[:by_name]
154
+ by = verdict[:by].nil? ? "You" : "A participant" unless present?(by)
155
+ reason = clip(verdict[:reason].to_s, 120)
156
+ "#{by}: #{icon} #{result} — #{reason}"
157
+ end
158
+ private_class_method :verdict_text
159
+
140
160
  # notify.js Number(turn.seconds): anything that isn't a number is 0.
141
161
  def seconds(turn)
142
162
  value = turn["seconds"]