samagotchi 0.15.0 → 0.16.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +10 -0
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +3 -1
  6. data/docs/guardrails.md +16 -2
  7. data/docs/sessions.md +7 -2
  8. data/docs/sub-agent.md +15 -3
  9. data/lib/samagotchi/answer_display.rb +6 -2
  10. data/lib/samagotchi/bridge/card_store.rb +16 -1
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  12. data/lib/samagotchi/bridge.rb +84 -0
  13. data/lib/samagotchi/bridge_client.rb +33 -6
  14. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  16. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  17. data/lib/samagotchi/engine.rb +51 -13
  18. data/lib/samagotchi/kernel_loop.rb +16 -5
  19. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  20. data/lib/samagotchi/llm/model_result.rb +12 -7
  21. data/lib/samagotchi/log_subscriber.rb +1 -0
  22. data/lib/samagotchi/parent_report.rb +8 -0
  23. data/lib/samagotchi/question_desk.rb +97 -18
  24. data/lib/samagotchi/relay.rb +88 -0
  25. data/lib/samagotchi/relay_desk.rb +73 -0
  26. data/lib/samagotchi/relay_verifier.rb +113 -0
  27. data/lib/samagotchi/relay_watcher.rb +46 -0
  28. data/lib/samagotchi/reply_wait.rb +10 -1
  29. data/lib/samagotchi/session.rb +15 -2
  30. data/lib/samagotchi/session_manager.rb +3 -2
  31. data/lib/samagotchi/session_metrics.rb +17 -0
  32. data/lib/samagotchi/sessions_command.rb +10 -4
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +69 -18
  35. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  36. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  37. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  38. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  39. data/lib/samagotchi/tool_declarations.rb +2 -2
  40. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  42. data/lib/samagotchi/tools/peers.rb +6 -4
  43. data/lib/samagotchi/turn_note.rb +30 -2
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +20 -0
  46. data/lib/samagotchi/web/public/app.js +24 -6
  47. data/lib/samagotchi/web/public/index.html +7 -0
  48. data/lib/samagotchi/web/public/notify.js +18 -5
  49. data/lib/samagotchi/web/public/question_card.js +45 -3
  50. data/lib/samagotchi/web/public/question_cards.js +49 -1
  51. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  52. data/lib/samagotchi/web/public/stage_model.js +8 -3
  53. data/lib/samagotchi/web/public/stage_view.js +7 -2
  54. data/lib/samagotchi/web/public/timing.js +11 -3
  55. data/lib/samagotchi/web/public/turn_events.js +9 -0
  56. data/lib/samagotchi/web/public/turn_view.js +34 -12
  57. metadata +6 -1
@@ -8,8 +8,26 @@ export function isApproval(pq) {
8
8
  return !!pq && pq.kind === "approval";
9
9
  }
10
10
 
11
- // @return {null | {tool, what, isCommand, where, why, preview?}}; preview is
12
- // an edit/write's dry-run diff (diff_view.js draws it)
11
+ // A delegate's approval relayed to this session's user (the approval
12
+ // relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
13
+ // task, or null.
14
+ // @return {null | {who, childId, childShort, task, more}}
15
+ export function relayView(pq) {
16
+ const r = pq && pq.relay;
17
+ if (!r || typeof r !== "object" || !r.child_id) return null;
18
+ const chain = Array.isArray(r.chain) && r.chain.length ? r.chain : [String(r.child_id).slice(0, 8)];
19
+ return {
20
+ who: [...chain].reverse().join(" → "),
21
+ childId: String(r.child_id),
22
+ childShort: r.child_short || String(r.child_id).slice(0, 8),
23
+ task: r.task ? String(r.task) : "",
24
+ more: Number(r.more) || 0,
25
+ };
26
+ }
27
+
28
+ // @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
29
+ // preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
30
+ // is relayView's, for a delegate's approval
13
31
  export function approvalView(pq) {
14
32
  if (!isApproval(pq)) return null;
15
33
  const a = pq.approval || {};
@@ -33,12 +51,34 @@ export function approvalView(pq) {
33
51
  where,
34
52
  why: `${reason} (${who})`,
35
53
  ...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
54
+ ...(relayView(pq) ? { delegate: relayView(pq) } : {}),
36
55
  };
37
56
  }
38
57
 
58
+ // This session's question waits in its parent's card too (the parent
59
+ // relayed it): the parent's id and short id, or null.
60
+ // @return {null | {parentId, parentShort}}
61
+ export function relayedToView(pq) {
62
+ const r = pq && pq.relayed_to;
63
+ if (!r || typeof r !== "object" || !r.parent_id) return null;
64
+ return { parentId: String(r.parent_id), parentShort: r.parent_short || String(r.parent_id).slice(0, 8) };
65
+ }
66
+
67
+ // How a relayed approval's card closed without an answer here: the
68
+ // delegate's question was answered there, its worker went, or this turn
69
+ // stopped (the question still waits in the delegate). A dismiss here
70
+ // denied it.
71
+ function relayClosedText(relay, reason) {
72
+ if (reason === "answered_on_child") return `Answered in ${relay.who}`;
73
+ if (reason === "child_gone") return `${relay.who}'s worker is gone`;
74
+ return reason ? `Left open in ${relay.who} (${reason})` : `Left open in ${relay.who}`;
75
+ }
76
+
39
77
  // The line a resolved card shows.
40
78
  export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
41
79
  const approval = isApproval(pq);
80
+ const relay = relayView(pq);
81
+ if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
42
82
  if (cancelled) {
43
83
  const label = approval ? "Denied" : "Cancelled";
44
84
  return reason ? `${label} (${reason})` : label;
@@ -70,8 +110,10 @@ export function truncate(text, n = 80) {
70
110
  export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
71
111
  if (cancelled || answer) {
72
112
  const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
113
+ const relay = relayView(pq);
73
114
  const asked = summaryAsked(pq);
74
- return truncate(asked ? `${truncate(asked, 60)} → ${result}` : result, 120);
115
+ const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
116
+ return truncate(relay ? `${relay.who}: ${text}` : text, 120);
75
117
  }
76
118
  const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
77
119
  return truncate(pq.header || label);
@@ -14,7 +14,8 @@
14
14
 
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffHtml } from "./diff_view.js";
17
- import { approvalAllowed, approvalView, nextCardAction, resultText, summaryText } from "./question_card.js";
17
+ import { approvalAllowed, approvalView, nextCardAction, relayedToView, resultText, summaryText } from "./question_card.js";
18
+ import { sessionHash } from "./route.js";
18
19
  import { clientLabel, continueLine, isOwn } from "./turn_events.js";
19
20
 
20
21
  // @param sessionId () => the open session's id (answers go there)
@@ -64,6 +65,8 @@ export function createQuestionCards({
64
65
  // left it (rememberToggle-style, as turn_view.js does).
65
66
  summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
66
67
 
68
+ // Relayed to the parent: say where else it waits (QuestionDesk#annotate).
69
+ card.appendChild(renderRelayedTo(pq));
67
70
  if (approval) {
68
71
  card.appendChild(renderApprovalDetails(approval));
69
72
  } else {
@@ -170,6 +173,23 @@ export function createQuestionCards({
170
173
  function renderApprovalDetails(view) {
171
174
  const box = doc.createElement("div");
172
175
  box.className = "approval-details";
176
+ // A delegate's approval: who asks, and its task.
177
+ if (view.delegate) {
178
+ const who = doc.createElement("div");
179
+ who.className = "approval-delegate";
180
+ const label = doc.createElement("span");
181
+ label.className = "approval-label";
182
+ label.textContent = "delegate ";
183
+ who.appendChild(label);
184
+ const name = doc.createElement("a");
185
+ name.className = "approval-delegate-link";
186
+ name.href = sessionHash(view.delegate.childId);
187
+ name.title = "Open the delegate's session";
188
+ name.textContent = view.delegate.who;
189
+ who.appendChild(name);
190
+ if (view.delegate.task) who.appendChild(doc.createTextNode(` · ${view.delegate.task}`));
191
+ box.appendChild(who);
192
+ }
173
193
  const tool = doc.createElement("div");
174
194
  tool.className = "approval-tool";
175
195
  tool.textContent = view.tool;
@@ -203,6 +223,32 @@ export function createQuestionCards({
203
223
  return box;
204
224
  }
205
225
 
226
+ // "Waiting for approval in parent ab12: answering here works too", or
227
+ // an empty (hidden) line while the question isn't relayed.
228
+ function renderRelayedTo(pq) {
229
+ const line = doc.createElement("div");
230
+ line.className = "question-relayed";
231
+ const view = relayedToView(pq);
232
+ if (!view) {
233
+ line.classList.add("hidden");
234
+ return line;
235
+ }
236
+ line.appendChild(doc.createTextNode(`Waiting for ${pq.kind === "approval" ? "approval" : "an answer"} in parent `));
237
+ const link = doc.createElement("a");
238
+ link.href = sessionHash(view.parentId);
239
+ link.textContent = view.parentShort;
240
+ line.appendChild(link);
241
+ line.appendChild(doc.createTextNode(": answering here works too"));
242
+ return line;
243
+ }
244
+
245
+ // The parent relayed the open question, or no longer does (question_relay).
246
+ function markRelayed(id, relayedTo) {
247
+ if (!questionEl || !pending || String(pending.id) !== String(id)) return;
248
+ pending = { ...pending, relayed_to: relayedTo || null };
249
+ questionEl.querySelector(".question-relayed")?.replaceWith(renderRelayedTo(pending));
250
+ }
251
+
206
252
  function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
207
253
  if (!questionEl) return;
208
254
  if (id && pending && String(pending.id) !== String(id)) return;
@@ -225,6 +271,7 @@ export function createQuestionCards({
225
271
  questionEl.appendChild(note);
226
272
  questionEl.classList.remove("submitting");
227
273
  disableQuestionInputs();
274
+ questionEl.querySelector(".question-relayed")?.classList.add("hidden");
228
275
  // The summary becomes the result; the card collapses to that one line
229
276
  // unless the user opened or closed it by hand.
230
277
  const summary = questionEl.querySelector("summary");
@@ -343,6 +390,7 @@ export function createQuestionCards({
343
390
  return {
344
391
  renderQuestion: renderQuestionCard,
345
392
  resolveQuestion: resolveQuestionCard,
393
+ markRelayed,
346
394
  removeQuestion: removeQuestionCard,
347
395
  questionCard: () => questionEl,
348
396
  pendingQuestion: () => pending,
@@ -78,7 +78,14 @@ const WAITING_BADGES = {
78
78
  // @return null | {kind, text, title}
79
79
  export function waitingBadge(summary) {
80
80
  const waiting = waitingOn(summary);
81
- return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
81
+ if (!waiting) return null;
82
+ // A delegate's approval waiting in its parent's card (the approval relay).
83
+ const parent = summary.pending_question?.relayed_to;
84
+ if (parent && waiting.reason !== "card") {
85
+ return { kind: waiting.reason, text: `in parent ${parent}`,
86
+ title: `Waiting for your ${waiting.reason} in parent session ${parent} (answering here works too)` };
87
+ }
88
+ return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
82
89
  }
83
90
 
84
91
  // What the all view's search matches for a session that waits on the user:
@@ -88,6 +88,8 @@ export function isPlain(turn) {
88
88
  }
89
89
 
90
90
  const END_PHASES = { completed: "answered", canceled: "canceled" };
91
+ // A completed turn whose model returned nothing (the turn view's notice).
92
+ const NO_ANSWER = "no answer";
91
93
 
92
94
  // What the live slots show for +turn+:
93
95
  // phase the status row's word;
@@ -100,14 +102,17 @@ const END_PHASES = { completed: "answered", canceled: "canceled" };
100
102
  // trail the last three finished calls, newest last (a file's title
101
103
  // without its dir);
102
104
  // ticks one per call: {kind, status, step (the gen's index)}.
103
- // +pending+: a card waits for the user; +ended+: the turn's end kind.
104
- export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null } = {}) {
105
+ // +pending+: a card waits for the user; +ended+: the turn's end kind;
106
+ // +emptyAnswer+: a completed turn ended with no answer
107
+ // (turn_summary.empty_answer), so it reads "no answer", not "answered".
108
+ export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
105
109
  const gens = turn?.gens || [];
106
110
  const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
107
111
  const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
108
112
  const current = gens[gens.length - 1];
109
113
  let phase;
110
- if (ended) phase = END_PHASES[ended] || "failed";
114
+ if (ended === "completed" && emptyAnswer) phase = NO_ANSWER;
115
+ else if (ended) phase = END_PHASES[ended] || "failed";
111
116
  else if (pending) phase = "waiting for you";
112
117
  else if (running) phase = "running a tool";
113
118
  else if (current?.text && isPlain(turn)) phase = "writing the answer";
@@ -26,7 +26,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
26
26
  // asks the user too, but the turn goes on: it waits only while the turn runs
27
27
  // and never holds the hand-off (a turn cut short can leave it open).
28
28
  const ASKS = ".plugin-card[data-in-turn] .card-action";
29
- const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
29
+ const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
30
30
  // The mark of a flash in the trail: a warn notice, a hook's info notice, a
31
31
  // plugin's nudge (steer).
32
32
  const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
@@ -93,6 +93,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
93
93
  // The turn in the stage, from turnStarted until the hand-off (or a reset).
94
94
  let holding = false;
95
95
  let endKind = null;
96
+ // The ended turn had no answer (turn_summary.empty_answer).
97
+ let emptyAnswer = false;
96
98
  let settled = true;
97
99
  let settleTimer = null;
98
100
  let startedAt = 0;
@@ -175,7 +177,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
175
177
  // A question blocks the turn: its card takes the live slots' room. An
176
178
  // asking card's turn goes on: the status waits, the slots stay.
177
179
  const blocked = pendingCard();
178
- const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind });
180
+ const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
179
181
 
180
182
  stage.classList.toggle("plain", plain);
181
183
  stage.classList.toggle("ended", !!endKind);
@@ -452,6 +454,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
452
454
  clearTimeout(settleTimer);
453
455
  holding = false;
454
456
  endKind = null;
457
+ emptyAnswer = false;
455
458
  settled = true;
456
459
  view.reset();
457
460
  emptySlots();
@@ -517,6 +520,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
517
520
  }
518
521
  holding = true;
519
522
  endKind = null;
523
+ emptyAnswer = false;
520
524
  settled = true;
521
525
  startedAt = Date.parse(data?.started_at) || Date.now();
522
526
  emptySlots();
@@ -542,6 +546,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
542
546
  kept.push(...warn);
543
547
  view.chronological();
544
548
  endKind = args.kind;
549
+ emptyAnswer = !!args.emptyAnswer;
545
550
  endedAt = Date.now();
546
551
  // A completed turn's end read (markdown, answer_display) is pending
547
552
  // until answerReady; the others have none.
@@ -88,7 +88,9 @@ export function timedTurnIndexes(items) {
88
88
  // the last, in order; a text-less iteration leaves no message, so a text
89
89
  // can sit one step early: inspection, not truth) and its answer (the last
90
90
  // assistant message). Assistant messages before any prompt are answers on
91
- // their own. The turn index counts prompts, as timedTurnIndexes does. The
91
+ // their own. A turn that ended with no answer (the server's empty_answer
92
+ // item) has no answer: its texts are steps, and `emptyAnswer` is that
93
+ // item's index. The turn index counts prompts, as timedTurnIndexes does. The
92
94
  // user's lines merged into the running turn (`merged`, the server's
93
95
  // Steer::INPUT_KIND) stay in it: the turn's `merged` list.
94
96
  export function turnGroups(items, timing) {
@@ -110,13 +112,18 @@ export function turnGroups(items, timing) {
110
112
  } else if (item?.role === "assistant") {
111
113
  if (turn) turn.texts.push(i);
112
114
  else groups.push({ kind: "answer", answer: i });
115
+ } else if (item?.role === "empty_answer" && turn) {
116
+ turn.emptyAnswer = i;
113
117
  }
114
118
  });
115
119
  return groups.map((g) => {
116
120
  if (g.kind !== "turn") return g;
117
121
  const group = withSteers(turnGroup(g, items, timing), g.steers, items);
122
+ // A turn that ended with no answer: its notice (the item's index) in
123
+ // the answer's place.
124
+ const ended = g.emptyAnswer == null ? group : { ...group, emptyAnswer: g.emptyAnswer };
118
125
  // A turn with no merged lines keeps its shape.
119
- return g.merged.length ? { ...group, merged: g.merged } : group;
126
+ return g.merged.length ? { ...ended, merged: g.merged } : ended;
120
127
  });
121
128
  }
122
129
 
@@ -150,7 +157,8 @@ function promptRecord(timing, g) {
150
157
  function turnGroup(g, items, timing) {
151
158
  const record = promptRecord(timing, g);
152
159
  const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
153
- const stepsOnly = canceledSteps(g, record, tools);
160
+ // A turn with no answer has none of its texts as one: they are steps.
161
+ const stepsOnly = canceledSteps(g, record, tools) || g.emptyAnswer != null;
154
162
  if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
155
163
  const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
156
164
  const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
@@ -64,6 +64,15 @@ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
64
64
  return `↻ ${what}, asking again (${attempt}/${of})`;
65
65
  }
66
66
 
67
+ // The one muted line for a turn that ended with no answer (turn_completed's
68
+ // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
69
+ // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
70
+ export function emptyAnswerLine({ retries = 0 } = {}) {
71
+ const count = Number(retries) || 0;
72
+ const after = count > 0 ? ` (after ${count} ${count === 1 ? "retry" : "retries"})` : "";
73
+ return `no answer: the model returned nothing${after}`;
74
+ }
75
+
67
76
  // The text of a turn's row from a snapshot entry (CardStore): a hook's
68
77
  // notice as "<label>: text", the loop's asking-again row as its line.
69
78
  export function noticeLine(entry = {}) {
@@ -16,7 +16,7 @@ import { activityDiffHtml } from "./diff_view.js";
16
16
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
17
  import { thumbsHtml } from "./images.js";
18
18
  import { shouldFollowScroll } from "./scroll.js";
19
- import { hookNoticeLabel } from "./turn_events.js";
19
+ import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
20
20
  import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
21
21
  import { createSentenceFeed } from "./sentences.js";
22
22
  import { createTicker } from "./thinking_ticker.js";
@@ -465,11 +465,15 @@ export function createTurnView({
465
465
  // +heldHeight+ (the live box's height, measured before the step closed) the
466
466
  // bubble is held at that height, scrolled to its tail, until answerReady()
467
467
  // (the rendered markdown landed) or 1.5 s reveals it.
468
- function promote(gen, { heldHeight = 0 } = {}) {
468
+ // +endNote+: the notice of a turn that ended with no answer (app.js
469
+ // builds it), placed where the answer bubble would go.
470
+ function promote(gen, { heldHeight = 0, endNote = null } = {}) {
469
471
  const entry = genEls.get(gen);
470
472
  const bubble = entry.text;
471
- bubble.className = "bubble output";
472
- if (!gen.text) bubble.textContent = "";
473
+ // No text (an empty answer): no bubble, not even an empty one left in
474
+ // the step.
475
+ if (gen.text) bubble.className = "bubble output";
476
+ else bubble.remove();
473
477
  // A hidden tab gets no pop: nobody sees it, and its timers are throttled.
474
478
  const holding = useHold && heldHeight > 0 && !!gen.text && !document.hidden;
475
479
  let holdHeight = 0;
@@ -512,11 +516,12 @@ export function createTurnView({
512
516
  }
513
517
  pieces.push(...cards);
514
518
  if (gen.text) pieces.push(bubble);
519
+ else if (endNote) pieces.push(endNote);
515
520
  placeAnswer(pieces, { plain: true, blockEl });
516
521
  blockEl = null;
517
522
  blockSummaryEl = null;
518
523
  } else {
519
- placeAnswer(gen.text ? [...cards, bubble] : cards, { plain: false, blockEl });
524
+ placeAnswer(gen.text ? [...cards, bubble] : [...cards, ...(endNote ? [endNote] : [])], { plain: false, blockEl });
520
525
  // The last step, left with its thinking only, reads "thinking" once.
521
526
  if (!gen.tools.length && gen.thinking) unwrapThinking(entry);
522
527
  }
@@ -524,8 +529,10 @@ export function createTurnView({
524
529
  }
525
530
 
526
531
  // Returns the cards that left the block (card.js leavesBlock), detached:
527
- // app.js puts them after the turn's end line.
528
- function turnEnded({ kind }) {
532
+ // app.js puts them after the turn's end line. +endNote+ (a completed turn
533
+ // with no answer): its notice, in the answer's place; left unplaced
534
+ // with no turn to end (app.js then shows it itself).
535
+ function turnEnded({ kind, endNote = null }) {
529
536
  // Once per turn: a second end (a re-dispatched event) would promote again.
530
537
  if (!turn || turn.ended) return [];
531
538
  const last = model.currentGen(turn);
@@ -541,7 +548,8 @@ export function createTurnView({
541
548
  const heldHeight = box ? box.getBoundingClientRect().height : 0;
542
549
  if (pendingSteers.length) flushSteers();
543
550
  apply(model.turnEnded(turn));
544
- if (promoting) promote(last, { heldHeight });
551
+ if (promoting) promote(last, { heldHeight, endNote });
552
+ else if (endNote) placeAnswer([endNote], { plain: false, blockEl });
545
553
  const kept = [];
546
554
  if (blockEl) {
547
555
  updateBlockSummary();
@@ -678,6 +686,13 @@ function reloadRowHtml(r, thumbs) {
678
686
  `${r.images?.length ? thumbs(r.images) : ""}${r.diff ? activityDiffHtml(r.diff) : ""}</div>`;
679
687
  }
680
688
 
689
+ // The notice of a turn that ended with no answer: a muted row like the
690
+ // loop's retry row, where the answer would be (live: promote()).
691
+ export const EMPTY_ANSWER_CLASS = "bubble hook-notice empty-answer";
692
+ function emptyAnswerHtml(item) {
693
+ return `<div class="${EMPTY_ANSWER_CLASS}">${escapeHtml(emptyAnswerLine(item))}</div>`;
694
+ }
695
+
681
696
  // A reloaded history in the turn view (renderHistory's object path): per
682
697
  // turn the prompt, a collapsed block of its steps when it had any (texts,
683
698
  // and with the server's parts the thinking, params and output, from the
@@ -708,9 +723,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
708
723
  const prompt = items[group.user];
709
724
  const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
710
725
  const [only] = group.steps;
711
- const noBlock = !group.steps.length ||
712
- (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking);
713
- if (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking) {
726
+ const ended = group.answer !== null || group.emptyAnswer != null;
727
+ const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
728
+ // A lone thinking step before an answer (or the notice of a turn with
729
+ // none): no block, as live.
730
+ const plainThinking = group.steps.length === 1 && ended && textless && !only.tools.length && only.thinking;
731
+ const noBlock = !group.steps.length || plainThinking;
732
+ if (plainThinking) {
714
733
  // A plain answer with thinking: the collapsed thinking block, as live.
715
734
  parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
716
735
  } else if (group.steps.length) {
@@ -736,9 +755,12 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
736
755
  // after its steps and before its answer.
737
756
  parts.push((group.merged || []).map((i) =>
738
757
  `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
758
+ // A turn that ended with no answer: chi's muted notice where the answer
759
+ // would be, before the timing, as live.
760
+ if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
739
761
  // A turn with no answer (canceled, or running: no record yet) shows its
740
762
  // timing after its prompt and steps, where the live view leaves it.
741
- if (group.answer === null) parts.push(timingHtml(group));
763
+ else if (group.answer === null) parts.push(timingHtml(group));
742
764
  else parts.push(answerHtml(items[group.answer], timingHtml(group)));
743
765
  // A canceled turn ends with its cancel line, as the live view left it.
744
766
  parts.push(cancelLineHtml(group.record, escapeHtml));
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.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -310,6 +310,10 @@ files:
310
310
  - lib/samagotchi/question_desk.rb
311
311
  - lib/samagotchi/recap_setup.rb
312
312
  - lib/samagotchi/recap_store.rb
313
+ - lib/samagotchi/relay.rb
314
+ - lib/samagotchi/relay_desk.rb
315
+ - lib/samagotchi/relay_verifier.rb
316
+ - lib/samagotchi/relay_watcher.rb
313
317
  - lib/samagotchi/reminder_queue.rb
314
318
  - lib/samagotchi/reminder_store.rb
315
319
  - lib/samagotchi/reply_wait.rb
@@ -366,6 +370,7 @@ files:
366
370
  - lib/samagotchi/tools/builtins.rb
367
371
  - lib/samagotchi/tools/cancel_reminder.rb
368
372
  - lib/samagotchi/tools/delegate.rb
373
+ - lib/samagotchi/tools/delegate_relay.rb
369
374
  - lib/samagotchi/tools/delegate_result.rb
370
375
  - lib/samagotchi/tools/delegate_wait.rb
371
376
  - lib/samagotchi/tools/edit.rb