samagotchi 0.97.0 → 0.99.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +11 -3
  4. data/docs/bundle-authoring.md +5 -3
  5. data/docs/cli.md +8 -3
  6. data/docs/configuration.md +8 -0
  7. data/docs/guardrails.md +8 -0
  8. data/docs/hooks.md +64 -0
  9. data/docs/internals/anthropic.md +79 -8
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +31 -3
  12. data/docs/internals/llm-context-forget.md +2 -1
  13. data/docs/internals/prompt-caching.md +9 -0
  14. data/docs/plugins.md +24 -2
  15. data/docs/sessions.md +11 -1
  16. data/docs/testing.md +27 -0
  17. data/lib/samagotchi/answer_refs.rb +16 -9
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +91 -10
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/kernel_loop.rb +15 -2
  27. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  28. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  29. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  30. data/lib/samagotchi/llm/dialect.rb +8 -5
  31. data/lib/samagotchi/llm/errors.rb +51 -17
  32. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  33. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  34. data/lib/samagotchi/llm_context_stale.rb +2 -2
  35. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  36. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  37. data/lib/samagotchi/message_parts.rb +2 -1
  38. data/lib/samagotchi/output_formatter.rb +8 -2
  39. data/lib/samagotchi/pending_question.rb +7 -3
  40. data/lib/samagotchi/plugin/api.rb +6 -1
  41. data/lib/samagotchi/plugin/context.rb +21 -2
  42. data/lib/samagotchi/prompt.rb +3 -0
  43. data/lib/samagotchi/question_asked.rb +83 -0
  44. data/lib/samagotchi/question_desk.rb +3 -2
  45. data/lib/samagotchi/testing/bundle.rb +20 -1
  46. data/lib/samagotchi/testing/host.rb +1 -0
  47. data/lib/samagotchi/testing/recorder.rb +13 -2
  48. data/lib/samagotchi/tool_loads.rb +62 -0
  49. data/lib/samagotchi/tool_response.rb +5 -1
  50. data/lib/samagotchi/tool_runner.rb +29 -7
  51. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  52. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  53. data/lib/samagotchi/tools/registry.rb +7 -2
  54. data/lib/samagotchi/tools/tool_index.rb +88 -6
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +30 -9
  57. data/lib/samagotchi/web/public/app.js +44 -11
  58. data/lib/samagotchi/web/public/index.html +36 -13
  59. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  60. data/lib/samagotchi/web/public/lists_view.js +32 -8
  61. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  62. data/lib/samagotchi/web/public/question_cards.js +23 -6
  63. data/lib/samagotchi/web/public/session_rename.js +88 -15
  64. data/lib/samagotchi/web/public/session_title.js +6 -0
  65. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  66. data/lib/samagotchi/web/public/stage_model.js +4 -2
  67. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  68. data/lib/samagotchi/web/public/stage_view.js +149 -8
  69. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  70. data/lib/samagotchi/web/public/turn_model.js +20 -0
  71. data/lib/samagotchi/web/public/turn_view.js +22 -9
  72. metadata +4 -1
@@ -1,10 +1,12 @@
1
1
  // The stage view's cap and frame (stage_view.js): the one place that
2
2
  // writes the stage's height, and the stage's own scrolls. The cap: at most
3
- // ~60% of the page (~80% with the cloud open), and never above the session
3
+ // ~60% of the page (~80% with the cloud open, 85% for the rest of a turn
4
+ // whose question got the question room), and never above the session
4
5
  // panel. The frame: held and grow-only while the stage holds a turn
5
6
  // (stage_model.js frameTarget), its growth eased. The stage's state comes
6
- // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+); what the stage
7
- // changes of the frame's goes through resetHeld and forgetContent.
7
+ // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+, +isRoomCap+);
8
+ // what the stage changes of the frame's goes through resetHeld and
9
+ // forgetContent.
8
10
  import { frameTarget } from "./stage_model.js";
9
11
  import { easeScrollTo } from "./scroll.js";
10
12
 
@@ -14,6 +16,11 @@ const GROW_MS = 180;
14
16
  const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
15
17
  // The room between the stage and the composer card (index.html's margin-bottom).
16
18
  export const STAGE_GAP = 10;
19
+ // The cap's share of the page: the stage's own, with the cloud open, and
20
+ // with the question room (a long reply that asks, stage_view.js).
21
+ const SHARE = 0.6;
22
+ const CLOUD_SHARE = 0.8;
23
+ const ROOM_SHARE = 0.85;
17
24
 
18
25
  export function reducedMotion() {
19
26
  return matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -21,7 +28,7 @@ export function reducedMotion() {
21
28
 
22
29
  // +observed+: the elements whose size changes refit the frame (the stage's
23
30
  // slots, in the order they are observed).
24
- export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen }) {
31
+ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false }) {
25
32
  // The frame (fitFrame): the height held while the turn runs and the
26
33
  // tallest content seen.
27
34
  let held = null;
@@ -45,25 +52,28 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
45
52
  }));
46
53
  }
47
54
 
48
- // ── the cap: at most ~60% of the page (~80% with the cloud open), and
49
- // never above the session panel (the dock is absolute: a tall composer or
50
- // a phone's keyboard would push the stage's top out) ────────────────────
55
+ // ── the cap: at most ~60% of the page (~80% with the cloud open, 85%
56
+ // once the turn's question got the room), and never above the session
57
+ // panel (the dock is absolute: a tall composer or a phone's keyboard
58
+ // would push the stage's top out) ──────────────────────────────────────
51
59
 
52
60
  // Where the stage's scroll goes so +slot+ (the scroller's child) shows:
53
61
  // its end in sight, and the asking text's top too when it and the slot
54
62
  // (a waiting card) fit together (the reply reads from its start, the card
55
- // still in sight). null: nothing to show.
56
- function revealTop(slot) {
63
+ // still in sight). +fromStart+ (the question room's arrival): the asking
64
+ // text's top even when they don't (the card is reached by the stage's
65
+ // jump pill, stage_view.js). null: nothing to show.
66
+ function revealTop(slot, { fromStart = false } = {}) {
57
67
  if (stage.hidden || slot.hidden) return null;
58
68
  let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
59
- if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
69
+ if (!asking.hidden && asking.offsetTop < top && (fromStart || slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight)) top = asking.offsetTop;
60
70
  return top;
61
71
  }
62
72
 
63
73
  // Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
64
- // dropped under it).
65
- function reveal(slot, { now = false } = {}) {
66
- const top = revealTop(slot);
74
+ // dropped under it). +fromStart+: revealTop's.
75
+ function reveal(slot, { now = false, fromStart = false } = {}) {
76
+ const top = revealTop(slot, { fromStart });
67
77
  if (top === null) return;
68
78
  if (now) scroller.scrollTop = top;
69
79
  else easeStage(top);
@@ -75,7 +85,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
75
85
  // out of the dock's flow).
76
86
  const rest = dockEl.offsetHeight + STAGE_GAP;
77
87
  const room = centerEl.clientHeight - rest - 32;
78
- const share = window.innerHeight * (isCloudOpen() ? 0.8 : 0.6);
88
+ const share = window.innerHeight * (isRoomCap() ? ROOM_SHARE : isCloudOpen() ? CLOUD_SHARE : SHARE);
79
89
  const cap = Math.max(96, Math.round(Math.min(share, room)));
80
90
  const was = capPx();
81
91
  if (cap === was) return;
@@ -6,7 +6,7 @@ import { toolName } from "./activity.js";
6
6
  import { splitSentences } from "./sentences.js";
7
7
  import { steerSender } from "./format.js";
8
8
  import { hookNoticeLabel } from "./turn_events.js";
9
- import { askingText } from "./turn_model.js";
9
+ import { askingRefs, askingText } from "./turn_model.js";
10
10
  import { plainLine } from "./plain_line.js";
11
11
 
12
12
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
@@ -156,7 +156,8 @@ const NO_ANSWER = "no answer";
156
156
  // without its dir; command as for tool);
157
157
  // ticks one per call: {kind, status, step (the gen's index)};
158
158
  // asking the whole text of the reply whose call a card blocks
159
- // (turn_model.js askingText), "" when none is blocked.
159
+ // (turn_model.js askingText), "" when none is blocked;
160
+ // askingRefs a plugin's refs on it (turn_model.js askingRefs), or null.
160
161
  // +pending+: a card waits for the user; +blocked+: a card that blocks the
161
162
  // turn (a question, an approval; not an action card) waits; +ended+: the
162
163
  // turn's end kind;
@@ -191,6 +192,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
191
192
  trail,
192
193
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
193
194
  asking: blocked && !ended ? askingText(turn) : "",
195
+ askingRefs: blocked && !ended ? askingRefs(turn) : null,
194
196
  };
195
197
  }
196
198
 
@@ -21,17 +21,23 @@ export function node(tag, cls, parent = null) {
21
21
  // command, read at its click); +onChange+ redraws (the stage's
22
22
  // scheduleRefresh). +renderMarkdown(text)+: the asking text's HTML
23
23
  // (Promise<string|null>; null keeps the raw text: markdown off, a failed
24
- // read); +linkRefs(el)+: the page's path:line ref pass on the slot.
24
+ // read); +linkRefs(el, refs)+: the page's ref pass on the slot (a
25
+ // plugin's +refs+ paired first, then the path:line ones).
25
26
  export function createStageSlots({
26
27
  headline, asking, now, trail, ticks, taskStop = null, gens, onChange,
27
28
  renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
28
29
  }) {
29
30
  // What each slot shows, so an unchanged slot is left alone.
30
- const shown = { headline: null, now: null, trail: null, asking: null };
31
+ const shown = { headline: null, now: null, trail: null, asking: null, askingRefs: "" };
32
+ // The refs the asking text is paired with (its pending question's).
33
+ let askingRefs = null;
31
34
  // The last asking text sent to render and its answer (in flight or
32
35
  // done, HTML only): the same question drawn again (after a reset, a
33
36
  // blank between) asks no second time.
34
37
  let rendering = { text: null, html: null };
38
+ // The asking text shows in its final form (its markdown swapped in, or
39
+ // a null render left the raw text): askingSettled.
40
+ let askingFinal = false;
35
41
 
36
42
  function drawHeadline({ text, thinking }) {
37
43
  const key = `${thinking ? "t" : "n"}|${text}`;
@@ -53,16 +59,28 @@ export function createStageSlots({
53
59
  // rewrites the same text): a selection in it survives. A new text reads
54
60
  // from its top: drawn raw at once, then swapped for its markdown when
55
61
  // that comes back while the slot still shows the same text (the text is
56
- // final: it shows only once the call that asks runs).
57
- function drawAsking(text) {
62
+ // final: it shows only once the call that asks runs). +refs+: a plugin's
63
+ // refs on it (the pending question's), paired on both draws; new refs on
64
+ // the same text (a second question in the step) are paired in place.
65
+ function drawAsking(text, refs = null) {
58
66
  asking.hidden = !text;
59
- if (shown.asking === text) return;
67
+ const refsKey = text && Array.isArray(refs) && refs.length ? JSON.stringify(refs) : "";
68
+ if (shown.asking === text) {
69
+ if (shown.askingRefs === refsKey) return;
70
+ shown.askingRefs = refsKey;
71
+ askingRefs = refsKey ? refs : null;
72
+ linkRefs(asking, askingRefs);
73
+ return;
74
+ }
60
75
  shown.asking = text;
76
+ shown.askingRefs = refsKey;
77
+ askingRefs = refsKey ? refs : null;
61
78
  asking.classList.remove("markdown", "md-compact");
62
79
  asking.textContent = text;
63
80
  asking.scrollTop = 0;
81
+ askingFinal = false;
64
82
  if (!text) return;
65
- linkRefs(asking);
83
+ linkRefs(asking, askingRefs);
66
84
  if (rendering.text !== text) {
67
85
  let html;
68
86
  try { html = Promise.resolve(renderMarkdown(text)).catch(() => null); } catch (_) { html = Promise.resolve(null); }
@@ -72,13 +90,24 @@ export function createStageSlots({
72
90
  pending.html.then((html) => {
73
91
  // A null isn't kept: the next draw of this text asks again.
74
92
  if (!html && rendering === pending) rendering = { text: null, html: null };
93
+ if (shown.asking !== text || askingFinal) return;
94
+ // The raw text is final (markdown off, a failed read): the stage
95
+ // measures it now.
96
+ if (!html) {
97
+ askingFinal = true;
98
+ onChange?.();
99
+ return;
100
+ }
75
101
  // Swapped once: a blank-then-same draw while in flight waits twice.
76
- if (!html || shown.asking !== text || asking.classList.contains("markdown")) return;
102
+ if (asking.classList.contains("markdown")) return;
77
103
  // Sanitised on the server (POST /api/render), as an answer's HTML is.
78
104
  asking.innerHTML = html;
79
105
  asking.classList.add("markdown", "md-compact");
80
106
  asking.scrollTop = 0;
81
- linkRefs(asking);
107
+ linkRefs(asking, askingRefs);
108
+ askingFinal = true;
109
+ // Its height changed: the stage measures it now (the question room).
110
+ onChange?.();
82
111
  });
83
112
  }
84
113
 
@@ -156,7 +185,10 @@ export function createStageSlots({
156
185
  asking.hidden = true;
157
186
  asking.classList.remove("markdown", "md-compact");
158
187
  shown.headline = shown.now = shown.trail = shown.asking = null;
188
+ shown.askingRefs = "";
189
+ askingRefs = null;
190
+ askingFinal = false;
159
191
  }
160
192
 
161
- return { drawHeadline, drawAsking, drawNow, drawTrail, drawTicks, reset };
193
+ return { drawHeadline, drawAsking, drawNow, drawTrail, drawTicks, reset, askingSettled: () => askingFinal };
162
194
  }
@@ -40,6 +40,10 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
40
40
  // asks the user too, but the turn goes on: it waits only while the turn runs
41
41
  // and never holds the hand-off (a turn cut short can leave it open).
42
42
  const ASKS = ".plugin-card[data-in-turn] .card-action";
43
+ // The model's own question (question_cards.js data-kind; not an approval,
44
+ // the step limit, a hook's question or a delegate's relayed one): the one
45
+ // card whose long asking text gets the question room.
46
+ const ASK_CARD = '.bubble.question[data-kind="question"]:not([data-relay])';
43
47
 
44
48
  function readCollapsed() {
45
49
  return readStored(COLLAPSE_KEY) === "1";
@@ -113,6 +117,14 @@ export function createStageView({
113
117
  chip.type = "button";
114
118
  const chipLabel = node("span", "ts-chip-label", chip);
115
119
  const ticks = node("span", "ts-ticks", chip);
120
+ // The question room's jump pill, at the scroller's bottom edge (absolute,
121
+ // above the chip): while the room's card is out of the stage's view it
122
+ // eases the stage to it (drawPill).
123
+ const pill = node("button", "ts-room-pill", cloud);
124
+ pill.type = "button";
125
+ pill.setAttribute("aria-label", "Go to the question");
126
+ pill.hidden = true;
127
+ pill.textContent = "❓ Answer ↓";
116
128
  // Below the turn's card: the lines queued for after it (placeKind
117
129
  // "queued"), each to run as a turn of its own; they hand off last.
118
130
  const queuedSlot = node("div", "ts-queued", stage);
@@ -140,6 +152,21 @@ export function createStageView({
140
152
  let revealed = false;
141
153
  let rafPending = false;
142
154
  let foldTimer = null;
155
+ // The question room (drawRoom): the pending ask card whose asking text
156
+ // overflowed its cap, and the turn's raised cap (stage_frame.js; kept
157
+ // until the next turn: the frame is grow-only).
158
+ let roomCard = null;
159
+ let roomCap = false;
160
+ // What the overflow was measured for ({ card, text }): measured once the
161
+ // asking text shows in its final form (its markdown, or the raw text when
162
+ // there is none: the raw text is taller, and a short question's could
163
+ // overflow where its markdown fits), again only for a new card or text,
164
+ // never at a cap's change (a phone's keyboard).
165
+ let measured = null;
166
+ // The ask card whose arrival reveal has run (no pill while the stage
167
+ // grows to it), and whether the room's card is in the stage's view.
168
+ let arrived = null;
169
+ let cardInSight = true;
143
170
 
144
171
  const view = createTurnView({
145
172
  historyEl,
@@ -181,6 +208,69 @@ export function createStageView({
181
208
  return !!(extras.querySelector(PENDING) || tail.querySelector(PENDING));
182
209
  }
183
210
 
211
+ // The pending card in the extras when it is the model's own question
212
+ // (never the tail's: a post-end card, the step limit; asking is empty then).
213
+ function askCard() {
214
+ const card = extras.querySelector(PENDING);
215
+ return card?.matches(ASK_CARD) ? card : null;
216
+ }
217
+
218
+ // The asking text overflows its cap (+1 px of rounding), measured with
219
+ // the room's class off.
220
+ function askingOverflows() {
221
+ return !asking.hidden && asking.scrollHeight > asking.clientHeight + 1;
222
+ }
223
+
224
+ // The question room: while a model's own question waits and its asking
225
+ // text didn't fit its cap, the slot loses its cap and the stage's one
226
+ // scroll reads the whole reply (index.html .question-room); the cap rises
227
+ // for the rest of the turn. The card keeps the room until it stops
228
+ // pending (+text+: the asking text drawn).
229
+ function drawRoom(blocked, text) {
230
+ const ask = blocked ? askCard() : null;
231
+ if (roomCard && ask === roomCard) return;
232
+ if (roomCard) {
233
+ cardSight.unobserve(roomCard);
234
+ stage.classList.remove("question-room");
235
+ roomCard = null;
236
+ }
237
+ if (!ask || !slotsView.askingSettled() || (measured && measured.card === ask && measured.text === text)) return;
238
+ measured = { card: ask, text };
239
+ if (!askingOverflows()) return;
240
+ roomCard = ask;
241
+ stage.classList.add("question-room");
242
+ cardInSight = true;
243
+ cardSight.observe(roomCard);
244
+ if (!roomCap) {
245
+ roomCap = true;
246
+ frame.fitCap();
247
+ }
248
+ // Decided after the card's arrival reveal (a slow markdown read): the
249
+ // reply reads from its start now, the pill only after that.
250
+ if (arrived === ask) {
251
+ arrived = null;
252
+ frame.afterGrowth(() => {
253
+ if (roomCard !== ask) return;
254
+ frame.reveal(extras, { fromStart: true });
255
+ arrived = ask;
256
+ drawPill();
257
+ });
258
+ }
259
+ }
260
+
261
+ // The pill: the room's card out of the stage's view, once its arrival
262
+ // reveal has run, the stage open.
263
+ function drawPill() {
264
+ pill.hidden = !(roomCard && !collapsed && arrived === roomCard && !cardInSight);
265
+ }
266
+
267
+ // The room's card in the stage's view: ~40 px of it shows above the
268
+ // scroller's bottom edge (where the pill sits).
269
+ const cardSight = new IntersectionObserver((entries) => {
270
+ for (const entry of entries) if (entry.target === roomCard) cardInSight = entry.isIntersecting;
271
+ drawPill();
272
+ }, { root: scroller, rootMargin: "0px 0px -40px 0px", threshold: 0 });
273
+
184
274
  function asksCard() {
185
275
  return !endKind && !!extras.querySelector(ASKS);
186
276
  }
@@ -214,7 +304,10 @@ export function createStageView({
214
304
  // next step (held-wait, set with waiting): the card under them never
215
305
  // moves at the answer. The waiting height is measured before the swap.
216
306
  const wasWaiting = stage.classList.contains("waiting");
217
- if (blocked && !wasWaiting) stage.classList.add("held-wait");
307
+ if (blocked && !wasWaiting) {
308
+ stage.classList.add("held-wait");
309
+ live.style.minHeight = "";
310
+ }
218
311
  if (!blocked && wasWaiting) stage.style.setProperty("--live-held", `${live.offsetHeight}px`);
219
312
  stage.classList.toggle("plain", plain);
220
313
  stage.classList.toggle("ended", !!endKind);
@@ -225,7 +318,9 @@ export function createStageView({
225
318
  phaseEl.textContent = slots.phase;
226
319
 
227
320
  slotsView.drawHeadline(slots.headline);
228
- slotsView.drawAsking(slots.asking);
321
+ slotsView.drawAsking(slots.asking, slots.askingRefs);
322
+ drawRoom(blocked, slots.asking);
323
+ drawPill();
229
324
  slotsView.drawNow(slots.tool, slots.quiet);
230
325
  slotsView.drawTrail(slots.trail, flash);
231
326
  slotsView.drawTicks(slots.ticks);
@@ -253,6 +348,25 @@ export function createStageView({
253
348
  tail.hidden = !tail.firstChild;
254
349
  }
255
350
 
351
+ // The next step: the live slots' waiting height is let go. In a turn
352
+ // with the question room that height was the whole reply's: it goes as
353
+ // far as the stage's scroll absorbs it (the scroll moves up by as much,
354
+ // in the same task: the card in view stays put), the rest kept as the
355
+ // live slots' least height until the next wait or turn.
356
+ function releaseHeldWait() {
357
+ const held = roomCap && stage.classList.contains("held-wait") && !stage.classList.contains("waiting");
358
+ const before = held ? live.offsetHeight : 0;
359
+ const top = scroller.scrollTop;
360
+ stage.classList.remove("held-wait");
361
+ stage.style.removeProperty("--live-held");
362
+ live.style.minHeight = "";
363
+ if (!held) return;
364
+ const after = live.offsetHeight;
365
+ const absorbed = Math.min(Math.max(0, before - after), top);
366
+ if (before - absorbed > after) live.style.minHeight = `${before - absorbed}px`;
367
+ if (absorbed) scroller.scrollTop = top - absorbed;
368
+ }
369
+
256
370
  // One line in the trail for FLASH_MS, ahead of the calls (a newer flash
257
371
  // takes its place).
258
372
  function flashTrail(what) {
@@ -339,6 +453,13 @@ export function createStageView({
339
453
  else setCloud(!cloudOpen);
340
454
  });
341
455
  headline.addEventListener("click", peek);
456
+ // To the card: its end in sight (the arrival's rule), or back up to it
457
+ // for a reader below it (the cloud open under the card).
458
+ pill.addEventListener("click", () => {
459
+ if (!roomCard) return;
460
+ if (roomCard.offsetTop < scroller.scrollTop) frame.easeStage(Math.max(0, roomCard.offsetTop - 8));
461
+ else frame.reveal(extras);
462
+ });
342
463
  // A ref's "show in turn" (refs.js) names a row in the cloud: open the
343
464
  // stage and the cloud to it, as a hand toggle would.
344
465
  // A collapsed stage opens for this turn only: the kept choice
@@ -378,6 +499,7 @@ export function createStageView({
378
499
  isHolding: () => holding,
379
500
  isCollapsed: () => collapsed,
380
501
  isCloudOpen: () => cloudOpen,
502
+ isRoomCap: () => roomCap,
381
503
  });
382
504
 
383
505
  // The in-use watch (stage_watch.js): an ended turn hands off once the
@@ -421,9 +543,16 @@ export function createStageView({
421
543
  thinkingLine = null;
422
544
  flash = null;
423
545
  clearTimeout(flashTimer);
546
+ if (roomCard) cardSight.unobserve(roomCard);
547
+ roomCard = null;
548
+ roomCap = false;
549
+ measured = null;
550
+ arrived = null;
551
+ pill.hidden = true;
424
552
  setCloud(false);
425
- stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use");
553
+ stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use", "question-room");
426
554
  stage.style.removeProperty("--live-held");
555
+ live.style.minHeight = "";
427
556
  }
428
557
 
429
558
  // The turn leaves the stage's care (the hand-off's start, a reset, a new
@@ -546,9 +675,7 @@ export function createStageView({
546
675
  refresh();
547
676
  },
548
677
  generationStarted(data, now = null) {
549
- // The next step: the live slots' waiting height is let go.
550
- stage.classList.remove("held-wait");
551
- stage.style.removeProperty("--live-held");
678
+ releaseHeldWait();
552
679
  view.generationStarted(data, now);
553
680
  },
554
681
  chunk: (data) => view.chunk(data),
@@ -600,6 +727,11 @@ export function createStageView({
600
727
  },
601
728
  containsLive: (el) => view.containsLive(el),
602
729
  askingText: () => view.askingText(),
730
+ // The asking slot pairs them on its next draw.
731
+ questionRefs(refs) {
732
+ view.questionRefs(refs);
733
+ scheduleRefresh();
734
+ },
603
735
  // The turn model and its live phase as the status row reads it (the
604
736
  // composer's delivery line).
605
737
  current: () => view.current(),
@@ -637,8 +769,17 @@ export function createStageView({
637
769
  else scheduleRefresh();
638
770
  // A card that waits for the user is brought into the stage's view: in
639
771
  // a short window the prompt and headline fill it and the card sat
640
- // below, out of sight (once the stage has grown for it).
641
- if (waits) frame.afterGrowth(() => frame.reveal(slot));
772
+ // below, out of sight (once the stage has grown for it). A question
773
+ // with the room reads from the reply's start instead (its pill leads
774
+ // to the card); its pill shows from here on.
775
+ if (waits) {
776
+ frame.afterGrowth(() => {
777
+ const ask = askCard();
778
+ frame.reveal(slot, { fromStart: !!ask && ask === roomCard });
779
+ arrived = ask;
780
+ drawPill();
781
+ });
782
+ }
642
783
  return true;
643
784
  },
644
785
  // Where the live timing line goes (the status row), null: the history.
@@ -332,7 +332,11 @@ export function createStreamHandlers(deps) {
332
332
  },
333
333
  question_requested: (data) => {
334
334
  const pq = data.pending_question;
335
- if (pq && pq.id) questionCards.renderQuestion(pq);
335
+ if (!pq || !pq.id) return;
336
+ // A plugin's refs on the reply that asks: on its step before the card
337
+ // draws the asking slot.
338
+ if (pq.refs) turnDom.questionRefs?.(pq.refs);
339
+ questionCards.renderQuestion(pq);
336
340
  },
337
341
  question_answered: (data) => {
338
342
  questionCards.resolveQuestion(data.id, { answer: data.answer });
@@ -174,6 +174,26 @@ export function askingText(turn) {
174
174
  return gen.text.trim();
175
175
  }
176
176
 
177
+ // A plugin's refs on the reply that asks (a question's refs, from its
178
+ // pending question): kept on the asking step's gen, the last one, while
179
+ // the turn goes on, for the stage's asking slot (askingRefs) and the
180
+ // step's closed text (turn_view.js). A non-array or an empty list keeps
181
+ // what the gen has. The gen, or null.
182
+ export function questionRefs(turn, refs) {
183
+ if (!turn || turn.ended || !Array.isArray(refs) || !refs.length) return null;
184
+ const gen = turn.gens[turn.gens.length - 1];
185
+ if (!gen) return null;
186
+ gen.refs = refs;
187
+ return gen;
188
+ }
189
+
190
+ // The refs on the asking text (questionRefs), while it shows
191
+ // (askingText's rule); else null.
192
+ export function askingRefs(turn) {
193
+ if (!askingText(turn)) return null;
194
+ return turn.gens[turn.gens.length - 1].refs || null;
195
+ }
196
+
177
197
  // The turn is over (completed, canceled, failed, the worker gone).
178
198
  export function turnEnded(turn) {
179
199
  const closed = closeCurrent(turn);
@@ -21,7 +21,7 @@
21
21
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
22
22
  // removed at the end: the answer bubble takes its place.
23
23
  import { html, nothing, render } from "./vendor/lit-html.js";
24
- import { setAnswerRefs } from "./answer_refs.js";
24
+ import { answerRefsOf, setAnswerRefs } from "./answer_refs.js";
25
25
  import { bindRowRefs } from "./refs.js";
26
26
  import { leavesBlock } from "./card.js";
27
27
  import { failedTurnText, normalize, sourceLabel } from "./format.js";
@@ -70,13 +70,14 @@ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking",
70
70
  // the newest last), the live "…" until the first one, or a closed step's
71
71
  // full text. None when there is nothing to say. A closed step's text is
72
72
  // set whole (.textContent): its refs (ref_scan.js) split it into spans
73
- // that a text part's update would leave behind.
74
- function textTemplate(text, { live, sentences, placeholder }) {
73
+ // that a text part's update would leave behind. +at+: a reloaded step's
74
+ // message index, for its refs (data-answer-at, as an answer's).
75
+ function textTemplate(text, { live, sentences, placeholder, at = null }) {
75
76
  if (live && sentences?.length) {
76
77
  return html`<div class="gen-text streaming"><div class="narration">${sentences.map((s) => html`<div class="sentence">${s}</div>`)}</div></div>`;
77
78
  }
78
79
  if (live) return placeholder ? html`<div class="gen-text streaming">…</div>` : nothing;
79
- return text ? html`<div class="gen-text" .textContent=${text}></div>` : nothing;
80
+ return text ? html`<div class="gen-text" data-answer-at=${at ?? nothing} .textContent=${text}></div>` : nothing;
80
81
  }
81
82
 
82
83
  // One step, live or reloaded. +gen+: { text, thinking, tools, answer } (a
@@ -86,16 +87,17 @@ function textTemplate(text, { live, sentences, placeholder }) {
86
87
  // answers, its +items+ (tool rows, notices, cards: nodes or templates; a
87
88
  // reload's are its rows' nodes), the thinking +body+ node, the ticker's
88
89
  // +line+, the narration's +sentences+ and "…" +placeholder+, +on+ (the
89
- // click and toggle handlers).
90
+ // click and toggle handlers). A reload's +at+: the step's message index
91
+ // (textTemplate).
90
92
  export function stepTemplate(gen, st = {}) {
91
93
  const { live = false, index = 0, open = live, steers = [], items = [], body = gen.thinking, line = null,
92
- sentences = null, placeholder = false, on = {} } = st;
94
+ sentences = null, placeholder = false, on = {}, at = null } = st;
93
95
  const text = gen.answer ? "" : gen.text || "";
94
96
  const label = live ? { repeats: false, content: `step ${index + 1}` } : closedLabel({ ...gen, text });
95
97
  const failed = !live && model.failedCount(gen.tools) > 0;
96
98
  // A step left with its thinking only reads "thinking" once.
97
99
  const wrapped = live || !!text || gen.tools.length > 0;
98
- return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
100
+ return html`<details class=${`gen${live ? " live" : ""}${failed ? " has-failed" : ""}`} .open=${open}><summary class=${label.repeats ? "repeats" : nothing} @click=${on.stepClick ?? nothing}>${label.content}</summary>${steers}${gen.thinking ? thinkingTemplate({ body, wrapped, line, on }) : nothing}${textTemplate(text, { live, sentences, placeholder, at })}${items.length ? html`<div class="activity-body">${items}</div>` : nothing}</details>`;
99
101
  }
100
102
 
101
103
  // A hook's line (hook_notice) as a step's row. data.line is a whole row of
@@ -254,6 +256,10 @@ export function createTurnView({
254
256
  live: !entry.closed, index: entry.index, open: entry.open, steers: entry.steers, items: entry.items,
255
257
  body: entry.thinkingText, line: entry.line, sentences: entry.sentences, placeholder: entry.placeholder, on: entry.on,
256
258
  }), entry.frag, { renderBefore: entry.end });
259
+ // A closed step that asked a question knows its refs (model.questionRefs):
260
+ // the page pairs them with its text at the turn's end (linkTextRefs).
261
+ const text = entry.closed && entry.gen.refs ? entry.el?.querySelector(":scope > .gen-text") : null;
262
+ if (text && !answerRefsOf(text)) setAnswerRefs(text, entry.gen.refs);
257
263
  }
258
264
 
259
265
  // A step's nodes (lit's start marker, the <details>, the end marker), to
@@ -776,6 +782,10 @@ export function createTurnView({
776
782
  // The running turn's asking reply (turn_model.js askingText): what a
777
783
  // question or approval card keeps once answered.
778
784
  askingText: () => model.askingText(turn),
785
+ // A question's refs (its pending question's) on the step that asks it.
786
+ questionRefs(refs) {
787
+ if (turn) model.questionRefs(turn, refs);
788
+ },
779
789
  step(i) {
780
790
  const entry = turn && genEls.get(turn.gens[i]);
781
791
  return entry ? { el: entry.el, summary: entry.el.querySelector(":scope > summary"), thinkingWrap: thinkingWrap(entry) } : null;
@@ -880,6 +890,8 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
880
890
  // The steers the step answered first, where the live rows went.
881
891
  steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
882
892
  items: gen.tools.length ? [rowNodes(gen.tools, thumbs)] : [],
893
+ // Its text knows its message's refs (a question's), as an answer does.
894
+ at: group.steps[k].i,
883
895
  }));
884
896
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
885
897
  outlines.push(gens);
@@ -910,8 +922,9 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
910
922
  const out = nodesOf(parts);
911
923
  // A finished turn's block as the live end leaves it: its outline, open.
912
924
  [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
913
- // Each answer bubble knows its message's refs (answer_refs.js; the
914
- // page pairs them after the render), the index stays out of the DOM.
925
+ // Each answer bubble and step text knows its message's refs
926
+ // (answer_refs.js; the page pairs them after the render), the index
927
+ // stays out of the DOM.
915
928
  for (const bubble of out.querySelectorAll("[data-answer-at]")) {
916
929
  setAnswerRefs(bubble, items[Number(bubble.dataset.answerAt)]?.display_refs);
917
930
  bubble.removeAttribute("data-answer-at");
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.97.0
4
+ version: 0.99.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -485,6 +485,7 @@ files:
485
485
  - lib/samagotchi/prompt_note.rb
486
486
  - lib/samagotchi/prompt_warmup.rb
487
487
  - lib/samagotchi/question_answer.rb
488
+ - lib/samagotchi/question_asked.rb
488
489
  - lib/samagotchi/question_desk.rb
489
490
  - lib/samagotchi/queued_command.rb
490
491
  - lib/samagotchi/reasoning_history.rb
@@ -589,6 +590,7 @@ files:
589
590
  - lib/samagotchi/tool_call_parser.rb
590
591
  - lib/samagotchi/tool_declarations.rb
591
592
  - lib/samagotchi/tool_ids.rb
593
+ - lib/samagotchi/tool_loads.rb
592
594
  - lib/samagotchi/tool_output_cut.rb
593
595
  - lib/samagotchi/tool_output_files.rb
594
596
  - lib/samagotchi/tool_response.rb
@@ -604,6 +606,7 @@ files:
604
606
  - lib/samagotchi/tools/builtins.rb
605
607
  - lib/samagotchi/tools/cancel_reminder.rb
606
608
  - lib/samagotchi/tools/context_read.rb
609
+ - lib/samagotchi/tools/deferred_tools.rb
607
610
  - lib/samagotchi/tools/delegate.rb
608
611
  - lib/samagotchi/tools/delegate_cursor.rb
609
612
  - lib/samagotchi/tools/delegate_relay.rb