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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +96 -3
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +5 -2
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client.rb +8 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +3 -0
- data/lib/samagotchi/children_status.rb +6 -1
- data/lib/samagotchi/engine.rb +43 -7
- data/lib/samagotchi/guardrail_wiring.rb +10 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +39 -8
- data/lib/samagotchi/session_participants.rb +10 -2
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/verdict.rb +20 -0
- data/lib/samagotchi/verify_anchor.rb +28 -0
- data/lib/samagotchi/verify_fresh.rb +275 -0
- data/lib/samagotchi/verify_fresh_origin.rb +71 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +10 -3
- data/lib/samagotchi/web/participant_access.rb +15 -4
- data/lib/samagotchi/web/participant_review.rb +7 -1
- data/lib/samagotchi/web/participant_view.rb +46 -5
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +43 -7
- data/lib/samagotchi/web/public/app.js +134 -8
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/data.js +2 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/notify.js +13 -2
- data/lib/samagotchi/web/public/participant.html +3 -1
- data/lib/samagotchi/web/public/participant.js +113 -9
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/send_turn.js +6 -1
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verdict_card.js +65 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
- data/lib/samagotchi/web/public/verify_card.css +17 -0
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/web/push/attention.rb +25 -5
- data/lib/samagotchi/web/push/notifier.rb +4 -3
- data/lib/samagotchi/web/session_control.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +25 -1
- data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
- data/lib/samagotchi/worker.rb +125 -12
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- data/lib/samagotchi/worker_inbound.rb +4 -4
- 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
|
-
|
|
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" :
|
|
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" },
|
|
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
|
-
|
|
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
|
|
553
|
-
// is sent. No plugin pills: a
|
|
554
|
-
// owner's participant view, nor
|
|
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\/([^/?#]+)
|
|
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
|
|
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
|
|
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+:
|
|
74
|
-
# ran long or nobody watched it. A delegated
|
|
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 =
|
|
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"]
|