samagotchi 0.143.0 → 0.145.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/docs/bundle-authoring.md +1 -1
  4. data/docs/cli.md +21 -4
  5. data/docs/configuration.md +1 -1
  6. data/docs/guardrails.md +6 -3
  7. data/docs/hooks.md +5 -1
  8. data/docs/internals/bridge.md +2 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/internals/web-frontend.md +59 -2
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +3 -2
  14. data/docs/testing.md +1 -1
  15. data/lib/samagotchi/bridge/sse_writer.rb +11 -1
  16. data/lib/samagotchi/bundle_command/check.rb +13 -2
  17. data/lib/samagotchi/bundle_settings_check.rb +34 -1
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +13 -6
  20. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +4 -2
  25. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +4 -2
  26. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/bundles/work-cap/manifest.yml +4 -4
  29. data/lib/samagotchi/bundles/work-cap/plugin.rb +17 -8
  30. data/lib/samagotchi/context_status.rb +1 -1
  31. data/lib/samagotchi/engine.rb +17 -7
  32. data/lib/samagotchi/kernel_loop.rb +7 -0
  33. data/lib/samagotchi/live_versions.rb +28 -2
  34. data/lib/samagotchi/llm/chat_loop.rb +5 -0
  35. data/lib/samagotchi/llm/utf8_scrub.rb +5 -3
  36. data/lib/samagotchi/plugin/api.rb +2 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/peer.rb +1 -1
  39. data/lib/samagotchi/plugin_session_state.rb +6 -3
  40. data/lib/samagotchi/self_report.rb +15 -13
  41. data/lib/samagotchi/session_access/local.rb +7 -10
  42. data/lib/samagotchi/session_commands.rb +2 -1
  43. data/lib/samagotchi/system_one/answer.rb +8 -5
  44. data/lib/samagotchi/system_one/client.rb +17 -0
  45. data/lib/samagotchi/system_one/http_backend.rb +23 -10
  46. data/lib/samagotchi/testing/boundary.rb +76 -0
  47. data/lib/samagotchi/testing/env_not_shown.rb +17 -0
  48. data/lib/samagotchi/testing.rb +1 -0
  49. data/lib/samagotchi/update_command.rb +8 -7
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/annotate_bar.js +1 -1
  52. data/lib/samagotchi/web/public/app.js +15 -3
  53. data/lib/samagotchi/web/public/chip_popover.js +14 -4
  54. data/lib/samagotchi/web/public/composer_fold.js +5 -2
  55. data/lib/samagotchi/web/public/composer_grip.js +45 -2
  56. data/lib/samagotchi/web/public/copy.js +1 -1
  57. data/lib/samagotchi/web/public/index.html +79 -40
  58. data/lib/samagotchi/web/public/info_bar_model.js +6 -4
  59. data/lib/samagotchi/web/public/more_sheet.js +212 -0
  60. data/lib/samagotchi/web/public/question_cards.js +26 -3
  61. data/lib/samagotchi/web/public/ref_scan.js +4 -2
  62. data/lib/samagotchi/web/public/stage_frame.js +58 -13
  63. data/lib/samagotchi/web/public/stage_model.js +2 -1
  64. data/lib/samagotchi/web/public/stage_view.js +52 -20
  65. data/lib/samagotchi/web/public/turn_block.js +43 -9
  66. data/lib/samagotchi/worker.rb +21 -4
  67. metadata +3 -1
@@ -1,12 +1,13 @@
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, 85% for the rest of a turn
4
- // whose question got the question room), and never above the session
5
- // panel. The frame: held and grow-only while the stage holds a turn
6
- // (stage_model.js frameTarget), its growth eased. The stage's state comes
3
+ // ~60% of the page (~80% with the cloud open, 85% from a card that needs the
4
+ // room until the next step: a long reply's question, a tall approval), and
5
+ // never above the session panel. The frame: held and grow-only while the
6
+ // stage holds a turn (stage_model.js frameTarget), its growth eased; the next
7
+ // step after an answered card lets it go once (forgetContent). The stage's state comes
7
8
  // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+, +isRoomCap+);
8
9
  // what the stage changes of the frame's goes through resetHeld and
9
- // forgetContent.
10
+ // forgetContent. +waitingCard+: the card blocking the turn, or null.
10
11
  import { frameTarget } from "./stage_model.js";
11
12
  import { easeScrollTo } from "./scroll.js";
12
13
 
@@ -21,6 +22,11 @@ export const STAGE_GAP = 10;
21
22
  const SHARE = 0.6;
22
23
  const CLOUD_SHARE = 0.8;
23
24
  const ROOM_SHARE = 0.85;
25
+ // A waiting card read from its top sits this far under the scroller's edge
26
+ // (the scroller's own top padding).
27
+ const CARD_TOP_GAP = 8;
28
+ // A card this much in the stage's view counts as in sight.
29
+ const CARD_SEEN = 40;
24
30
 
25
31
  export function reducedMotion() {
26
32
  return matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -28,7 +34,7 @@ export function reducedMotion() {
28
34
 
29
35
  // +observed+: the elements whose size changes refit the frame (the stage's
30
36
  // slots, in the order they are observed).
31
- export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false }) {
37
+ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false, waitingCard = () => null }) {
32
38
  // The frame (fitFrame): the height held while the turn runs and the
33
39
  // tallest content seen.
34
40
  let held = null;
@@ -53,7 +59,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
53
59
  }
54
60
 
55
61
  // ── 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
62
+ // while a card has the room), and never above the session
57
63
  // panel (the dock is absolute: a tall composer or a phone's keyboard
58
64
  // would push the stage's top out) ──────────────────────────────────────
59
65
 
@@ -62,11 +68,16 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
62
68
  // (a waiting card) fit together (the reply reads from its start, the card
63
69
  // still in sight). +fromStart+ (the question room's arrival): the asking
64
70
  // 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.
71
+ // jump pill, stage_view.js). A waiting card in +slot+ taller than the
72
+ // view reads from its top, never from its options: the question and what
73
+ // it approves are what the answer is about; the options are a scroll away.
74
+ // null: nothing to show.
66
75
  function revealTop(slot, { fromStart = false } = {}) {
67
76
  if (stage.hidden || slot.hidden) return null;
68
77
  let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
69
78
  if (!asking.hidden && asking.offsetTop < top && (fromStart || slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight)) top = asking.offsetTop;
79
+ const card = waitingCard();
80
+ if (card && slot.contains(card)) top = Math.min(top, Math.max(0, card.offsetTop - CARD_TOP_GAP));
70
81
  return top;
71
82
  }
72
83
 
@@ -81,6 +92,36 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
81
92
  else easeStage(top);
82
93
  }
83
94
 
95
+ // Some of the waiting card shows in the stage's view (no card: nothing
96
+ // says otherwise).
97
+ function cardInSight() {
98
+ const card = waitingCard();
99
+ if (!card) return true;
100
+ const top = scroller.scrollTop;
101
+ return card.offsetTop + CARD_SEEN <= top + scroller.clientHeight && card.offsetTop + card.offsetHeight - CARD_SEEN >= top;
102
+ }
103
+
104
+ // The cap dropped under a waiting card in sight (a phone's keyboard, a
105
+ // rotation): it stays in sight. Someone typing in it keeps their field;
106
+ // while some of it still shows, the reader's scroll is left alone.
107
+ function keepCardInSight() {
108
+ const card = waitingCard();
109
+ const field = document.activeElement;
110
+ if (card?.contains(field) && field.matches('textarea, input:not([type="radio"], [type="checkbox"])')) {
111
+ field.scrollIntoView({ block: "nearest" });
112
+ return;
113
+ }
114
+ if (!card || !cardInSight()) reveal(card?.parentElement ?? extras, { now: true });
115
+ }
116
+
117
+ // +card+ fits the stage's view at the cap now: it, the scroller's padding
118
+ // and the stage's rows around the scroller.
119
+ function fitsCap(card) {
120
+ const css = getComputedStyle(scroller);
121
+ const around = stage.offsetHeight - scroller.offsetHeight + (parseFloat(css.paddingTop) || 0) + (parseFloat(css.paddingBottom) || 0);
122
+ return card.offsetHeight + around <= capPx();
123
+ }
124
+
84
125
  function fitCap() {
85
126
  if (stage.hidden) return;
86
127
  // The composer card and the gap above it (the stage floats over them,
@@ -91,14 +132,15 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
91
132
  const cap = Math.max(96, Math.round(Math.min(share, room)));
92
133
  const was = capPx();
93
134
  if (cap === was) return;
135
+ // Read before the cap changes the view. A reader who scrolled away from
136
+ // the card isn't brought back to it.
137
+ const keep = cap < was && stage.classList.contains("waiting") && cardInSight();
94
138
  stage.style.setProperty("--stage-max", `${cap}px`);
95
139
  fitFrame();
96
140
  // A smaller cap (a phone's keyboard came up) keeps a waiting card in
97
141
  // sight: the asking text above it would push it out. A larger one
98
142
  // leaves the reader's scroll alone.
99
- if (cap < was && stage.classList.contains("waiting")) {
100
- requestAnimationFrame(() => reveal(extras, { now: true }));
101
- }
143
+ if (keep) requestAnimationFrame(keepCardInSight);
102
144
  }
103
145
  // The whole dock, not only its composer card: the info bar under it
104
146
  // fills and wraps after a load (the session's facts arrive), and a cap
@@ -111,7 +153,8 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
111
153
  // Nothing in the stage view jumps. The rules: the stage is out of the
112
154
  // dock's flow (index.html), so its height never moves the composer; while
113
155
  // it holds a turn its height is held and grow-only (frameTarget), so a
114
- // content change happens inside it; growth eases (frameTo); the chip is a
156
+ // content change happens inside it (the next step after an answered card
157
+ // lets it go once: forgetContent); growth eases (frameTo); the chip is a
115
158
  // footer no content can push; the hand-off releases the turn at once
116
159
  // (releaseTurn) and empties the stage only after it slid away under the
117
160
  // composer (a transform, then finishFold/clearStage).
@@ -182,6 +225,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
182
225
 
183
226
  return {
184
227
  fitCap,
228
+ fitsCap,
185
229
  frameFree,
186
230
  afterGrowth,
187
231
  easeStage,
@@ -189,7 +233,8 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
189
233
  // A new turn, or none: no height held yet.
190
234
  resetHeld: () => { held = null; },
191
235
  // The tallest content seen and the height held for it are forgotten (a
192
- // new turn, the cloud closed by hand): the frame refits to what shows now.
236
+ // new turn, the cloud closed by hand, the next step after an answered
237
+ // card): the frame refits to what shows now.
193
238
  forgetContent: () => {
194
239
  maxContent = 0;
195
240
  held = null;
@@ -198,7 +198,8 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
198
198
 
199
199
  // The stage's frame height (px) while it holds a turn: grow-only, so a
200
200
  // content change inside it (the plain flip, an answered card, a short
201
- // answer) never moves its top edge. +natural+: its content's own height;
201
+ // answer) never moves its top edge. (Its one shrink forgets +held+: the next
202
+ // step after an answered card, stage_view.js releaseHeldWait.) +natural+: its content's own height;
202
203
  // +held+: the height it holds (null: none yet); +cap+: --stage-max. A lower
203
204
  // cap (a phone's keyboard) clamps it at once and the clamp is the new held
204
205
  // height: a cap that rises again doesn't bring the old one back, only new
@@ -36,6 +36,10 @@ const FLASH_MS = 4000;
36
36
  const ANSWER_SEEN = 72;
37
37
  // A short answer cut by less than this at its foot isn't scrolled to.
38
38
  const ANSWER_CUT = 12;
39
+ // An answered card's one line.
40
+ const ANSWERED_LINE = 40;
41
+ // That line brought back sits this far under the scroller's edge.
42
+ const ANSWERED_GAP = 8;
39
43
  const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
40
44
  // A running turn's card with actions (check-in's Nudge / Keep going / Stop)
41
45
  // asks the user too, but the turn goes on: it waits only while the turn runs
@@ -45,6 +49,9 @@ const ASKS = ".plugin-card[data-in-turn] .card-action";
45
49
  // the step limit, a hook's question or a delegate's relayed one): the one
46
50
  // card whose long asking text gets the question room.
47
51
  const ASK_CARD = '.bubble.question[data-kind="question"]:not([data-relay])';
52
+ // A blocking approval, a delegate's relayed one too: too tall for the stage's
53
+ // view, it gets the question room's raised cap (raiseForApproval).
54
+ const APPROVAL_CARD = '.bubble.question[data-kind="approval"]';
48
55
 
49
56
  function readCollapsed() {
50
57
  return readStored(COLLAPSE_KEY) === "1";
@@ -188,8 +195,9 @@ export function createStageView({
188
195
  let bare = false;
189
196
  let foldTimer = null;
190
197
  // The question room (drawRoom): the pending ask card whose asking text
191
- // overflowed its cap, and the turn's raised cap (stage_frame.js; kept
192
- // until the next turn: the frame is grow-only).
198
+ // overflowed its cap; and the raised cap (stage_frame.js), which an
199
+ // approval too tall for the stage gets too (raiseForApproval), kept until
200
+ // the next step (releaseHeldWait).
193
201
  let roomCard = null;
194
202
  let roomCap = false;
195
203
  // What the overflow was measured for ({ card, text }): measured once the
@@ -256,8 +264,8 @@ export function createStageView({
256
264
  // The question room: while a model's own question waits and its asking
257
265
  // text didn't fit its cap, the slot loses its cap and the stage's one
258
266
  // scroll reads the whole reply (index.html .question-room); the cap rises
259
- // for the rest of the turn. The card keeps the room until it stops
260
- // pending (+text+: the asking text drawn).
267
+ // until the next step. The card keeps the room until it stops pending
268
+ // (+text+: the asking text drawn).
261
269
  function drawRoom(blocked, text) {
262
270
  const ask = blocked ? askCard() : null;
263
271
  if (roomCard && ask === roomCard) return;
@@ -290,6 +298,18 @@ export function createStageView({
290
298
  }
291
299
  }
292
300
 
301
+ // A blocking approval too tall for the stage's view: the same raised cap,
302
+ // the stage takes what the page has. The card still opens at its question
303
+ // and command (stage_frame.js revealTop), so no room class and no pill. The
304
+ // cap is a ceiling: a card that fits never gets here.
305
+ function raiseForApproval(blocked) {
306
+ if (!blocked || roomCap) return;
307
+ const card = extras.querySelector(PENDING) || tail.querySelector(PENDING);
308
+ if (!card?.matches(APPROVAL_CARD) || frame.fitsCap(card)) return;
309
+ roomCap = true;
310
+ frame.fitCap();
311
+ }
312
+
293
313
  // The pill: the room's card out of the stage's view, once its arrival
294
314
  // reveal has run, the stage open.
295
315
  function drawPill() {
@@ -338,13 +358,13 @@ export function createStageView({
338
358
  const wasWaiting = stage.classList.contains("waiting");
339
359
  if (blocked && !wasWaiting) {
340
360
  stage.classList.add("held-wait");
341
- live.style.minHeight = "";
342
361
  }
343
362
  if (!blocked && wasWaiting) stage.style.setProperty("--live-held", `${live.offsetHeight}px`);
344
363
  stage.classList.toggle("plain", plain);
345
364
  stage.classList.toggle("ended", !!endKind);
346
365
  cancel.hidden = !onCancel || !!endKind;
347
366
  stage.classList.toggle("waiting", blocked);
367
+ if (!blocked && wasWaiting) settleAnsweredCard();
348
368
  stage.classList.toggle("asking", !!slots.asking);
349
369
  stage.dataset.phase = slots.phase;
350
370
  mark.textContent = MARKS[slots.phase] || "";
@@ -353,6 +373,7 @@ export function createStageView({
353
373
  slotsView.drawHeadline(slots.headline);
354
374
  slotsView.drawAsking(slots.asking, slots.askingRefs);
355
375
  drawRoom(blocked, slots.asking);
376
+ raiseForApproval(blocked);
356
377
  drawPill();
357
378
  slotsView.drawNow(slots.tool, slots.quiet);
358
379
  slotsView.drawTrail(slots.trail, flash);
@@ -386,6 +407,20 @@ export function createStageView({
386
407
  tail.hidden = !tail.firstChild;
387
408
  }
388
409
 
410
+ // A card stopped waiting and its slot has its cap and own scroll again: the
411
+ // slot's end shows (the answered card's line). The stage's scroll stays, so
412
+ // nothing moves at the tap (the frame holds, and the blank goes at the next
413
+ // step), unless the reader had scrolled down the tall card past its line:
414
+ // then the line comes back, to the view's top and no further.
415
+ function settleAnsweredCard() {
416
+ const slot = tail.firstChild ? tail : extras;
417
+ if (!slot.firstChild) return;
418
+ slot.scrollTop = slot.scrollHeight;
419
+ const end = slot.offsetTop + slot.offsetHeight;
420
+ if (scroller.scrollTop <= end - ANSWERED_LINE) return;
421
+ scroller.scrollTop = Math.max(0, Math.min(slot.lastElementChild.offsetTop, end - ANSWERED_LINE) - ANSWERED_GAP);
422
+ }
423
+
389
424
  // The prompt's first words (the working set's session tags say whose
390
425
  // turn a step was).
391
426
  function promptWords() {
@@ -393,23 +428,18 @@ export function createStageView({
393
428
  return text.length > 60 ? `${text.slice(0, 59)}\u2026` : text;
394
429
  }
395
430
 
396
- // The next step: the live slots' waiting height is let go. In a turn
397
- // with the question room that height was the whole reply's: it goes as
398
- // far as the stage's scroll absorbs it (the scroll moves up by as much,
399
- // in the same task: the card in view stays put), the rest kept as the
400
- // live slots' least height until the next wait or turn.
431
+ // The next step after a blocking card's answer: the one time a turn's
432
+ // frame shrinks (stage_frame.js forgetContent; grow-only otherwise). What
433
+ // the wait held goes together: the live slots' waiting height, a raised
434
+ // cap, the tallest content seen. +held-wait+ says it is due, so a later
435
+ // step, or a generation with the card still waiting, changes nothing.
401
436
  function releaseHeldWait() {
402
- const held = roomCap && stage.classList.contains("held-wait") && !stage.classList.contains("waiting");
403
- const before = held ? live.offsetHeight : 0;
404
- const top = scroller.scrollTop;
437
+ if (!stage.classList.contains("held-wait") || stage.classList.contains("waiting")) return;
405
438
  stage.classList.remove("held-wait");
406
439
  stage.style.removeProperty("--live-held");
407
- live.style.minHeight = "";
408
- if (!held) return;
409
- const after = live.offsetHeight;
410
- const absorbed = Math.min(Math.max(0, before - after), top);
411
- if (before - absorbed > after) live.style.minHeight = `${before - absorbed}px`;
412
- if (absorbed) scroller.scrollTop = top - absorbed;
440
+ roomCap = false;
441
+ frame.fitCap();
442
+ frame.forgetContent();
413
443
  }
414
444
 
415
445
  // One line in the trail for FLASH_MS, ahead of the calls (a newer flash
@@ -451,6 +481,8 @@ export function createStageView({
451
481
  revealed = false;
452
482
  writeStored(COLLAPSE_KEY, value ? "1" : "0");
453
483
  applyCollapsed();
484
+ // Opened on a waiting card: it shows as it does on arrival.
485
+ if (!value && pendingCard()) frame.afterGrowth(() => frame.reveal(tail.querySelector(PENDING) ? tail : extras));
454
486
  }
455
487
 
456
488
  function setCloud(open) {
@@ -546,6 +578,7 @@ export function createStageView({
546
578
  isCollapsed: () => collapsed,
547
579
  isCloudOpen: () => cloudOpen,
548
580
  isRoomCap: () => roomCap,
581
+ waitingCard: () => (stage.classList.contains("waiting") ? extras.querySelector(PENDING) || tail.querySelector(PENDING) : null),
549
582
  });
550
583
 
551
584
  // The in-use watch (stage_watch.js): an ended turn hands off once the
@@ -608,7 +641,6 @@ export function createStageView({
608
641
  setCloud(false);
609
642
  stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use", "question-room");
610
643
  stage.style.removeProperty("--live-held");
611
- live.style.minHeight = "";
612
644
  }
613
645
 
614
646
  // The turn leaves the stage's care (the hand-off's start, a reset, a new
@@ -45,6 +45,9 @@ import { groupGaps, timeGapTemplate } from "./time_gaps.js";
45
45
 
46
46
  // ---- The templates: one step, its thinking, its label ----
47
47
 
48
+ // Streaming text reaches the page at most this often (docs/internals/web-frontend.md, Frames).
49
+ const FLUSH_MS = 100;
50
+
48
51
  // A closed step's summary (model.genLabel). When its head is the words the
49
52
  // body starts with (the narration's first line, the first call's title)
50
53
  // and it has calls, the summary is marked .repeats and its parts are
@@ -218,7 +221,11 @@ export function createTurnBlock({
218
221
  // Steer rows waiting for the step that answers them (steer()).
219
222
  let pendingSteers = [];
220
223
  const dirty = new Set();
224
+ // A flush is pending: a timer running (flushTimer) or a frame requested.
221
225
  let rafPending = false;
226
+ let flushTimer = null;
227
+ // When the last flush wrote something (performance.now()).
228
+ let lastFlushAt = -Infinity;
222
229
 
223
230
  function removeHint() {
224
231
  const hint = historyEl.querySelector(".hint");
@@ -388,9 +395,26 @@ export function createTurnBlock({
388
395
  else addSentences(entry, entry.feed.feed(gen.text));
389
396
  }
390
397
 
398
+ function cancelTimer() {
399
+ clearTimeout(flushTimer);
400
+ flushTimer = null;
401
+ }
402
+
403
+ // Nothing pending from here on. A frame already requested can't be taken
404
+ // back this way: it runs flush with nothing to write.
405
+ function cancelFlush() {
406
+ cancelTimer();
407
+ dirty.clear();
408
+ rafPending = false;
409
+ }
410
+
391
411
  function flush() {
392
412
  rafPending = false;
393
- // Runs every animation frame while streaming — must not yank a user who
413
+ cancelTimer();
414
+ // A late frame with nothing to write (a direct flush, a turn's end came
415
+ // first) doesn't count: it would hold back the next first token.
416
+ if (dirty.size) lastFlushAt = performance.now();
417
+ // Runs up to every FLUSH_MS while streaming — must not yank a user who
394
418
  // scrolled up to read; only follow when they were already near the bottom.
395
419
  const stick = isNearBottom();
396
420
  for (const gen of dirty) writeGen(gen, { follow: true });
@@ -403,7 +427,16 @@ export function createTurnBlock({
403
427
  dirty.add(gen);
404
428
  if (rafPending) return;
405
429
  rafPending = true;
406
- requestAnimationFrame(flush);
430
+ const wait = lastFlushAt + FLUSH_MS - performance.now();
431
+ if (wait <= 0) {
432
+ requestAnimationFrame(flush);
433
+ return;
434
+ }
435
+ // Still on a frame after the timer: a hidden tab's flush pauses, as it always did.
436
+ flushTimer = setTimeout(() => {
437
+ flushTimer = null;
438
+ requestAnimationFrame(flush);
439
+ }, wait);
407
440
  }
408
441
 
409
442
  // A closed gen: its final text, collapsed to its label (unless the user
@@ -626,8 +659,7 @@ export function createTurnBlock({
626
659
  }
627
660
  kept.forEach((el) => el.remove());
628
661
  }
629
- dirty.clear();
630
- rafPending = false;
662
+ cancelFlush();
631
663
  onChange();
632
664
  return kept;
633
665
  }
@@ -655,8 +687,8 @@ export function createTurnBlock({
655
687
  for (const entry of genEls.values()) stopTicker(entry);
656
688
  genEls.clear();
657
689
  pendingSteers = [];
658
- dirty.clear();
659
- rafPending = false;
690
+ cancelFlush();
691
+ lastFlushAt = -Infinity;
660
692
  }
661
693
 
662
694
  return {
@@ -682,7 +714,9 @@ export function createTurnBlock({
682
714
  if (!text && !thinking) return;
683
715
  if (!turn) this.turnStarted({});
684
716
  const r = model.chunk(turn, { text, thinking, iteration, now });
685
- apply(r);
717
+ // A step's edge shows now; the text, and the stage's refresh that goes
718
+ // with it, wait for the flush (apply's onChange would refresh per frame).
719
+ if (r.opened || r.closed) apply(r);
686
720
  scheduleFlush(r.gen);
687
721
  },
688
722
  // A dropped stream's step asked again (generation_retrying with
@@ -704,8 +738,8 @@ export function createTurnBlock({
704
738
  if (!turn) return;
705
739
  const r = model.generationCompleted(turn);
706
740
  if (!r) return;
707
- // The text lands on the next animation frame; a generation can end
708
- // before one runs (a burst of frames, a hidden tab).
741
+ // The text lands at the next flush; a generation can end before one
742
+ // runs (inside FLUSH_MS, a hidden tab).
709
743
  writeGen(r.gen);
710
744
  // The remainder is the step's last line: narration without a final
711
745
  // period ("Let me check the shell first") would otherwise never show.
@@ -81,8 +81,11 @@ module Samagotchi
81
81
  # gets it over the Bridge a moment after it starts, and that message's
82
82
  # turn goes first (the reports join it).
83
83
  WAKE_START_GRACE = 2.0
84
- # How much of a command's output goes into its :command_ran.
85
- COMMAND_OUTPUT_LIMIT = 4096
84
+ # How much of a command's output goes into its :command_ran, in chars.
85
+ # Room for a real listing (/guardrails with a bundle's rules, /models):
86
+ # the event lives only in the Bridge's ring (256 events, in memory) and
87
+ # on the streams, a session file doesn't hold it.
88
+ COMMAND_OUTPUT_LIMIT = 64 * 1024
86
89
  BUSY_OUTPUT = "busy: wait for the turn to end"
87
90
  # /exit held by background tasks a finish wake waits for: how to end them.
88
91
  TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
@@ -1211,8 +1214,10 @@ module Samagotchi
1211
1214
  # line that applies it (the TUI's prefill, the web's Apply button)
1212
1215
  def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
1213
1216
  TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id, status: status)
1214
- text = output.to_s
1215
- event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
1217
+ # Any bytes (a !cmd's): one that JSON can't take would end every stream
1218
+ # that reaches this event, for as long as the ring holds it.
1219
+ text = output.to_s.scrub("?")
1220
+ event = { type: :command_ran, **command.event_fields, status: status, output: command_output_shown(text),
1216
1221
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
1217
1222
  event[:anytime] = true if anytime
1218
1223
  # The session's title after /title (nil: cleared), for the TUIs' row.
@@ -1228,6 +1233,18 @@ module Samagotchi
1228
1233
  @engine.announce(event)
1229
1234
  end
1230
1235
 
1236
+ # +text+ as its :command_ran carries it: over the limit, its head to a
1237
+ # line's end (one long line is cut where it must be), then a line that
1238
+ # says so: every UI prints this text as it is.
1239
+ def command_output_shown(text)
1240
+ return text if text.length <= COMMAND_OUTPUT_LIMIT
1241
+
1242
+ kept = text[0, COMMAND_OUTPUT_LIMIT]
1243
+ line_end = kept.rindex("\n")
1244
+ kept = kept[0, line_end] if line_end && line_end > COMMAND_OUTPUT_LIMIT / 2
1245
+ "#{kept}\n[cut: #{kept.length} of #{text.length} chars: a command's output stops here]"
1246
+ end
1247
+
1231
1248
  # One Engine turn, the same for a prompt, a reminder and a continue:
1232
1249
  # the session shows as running to readers of the file (the web's
1233
1250
  # session list; the Engine resets it to idle when it ends), commands
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.143.0
4
+ version: 0.145.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -682,6 +682,7 @@ files:
682
682
  - lib/samagotchi/testing.rb
683
683
  - lib/samagotchi/testing/boundary.rb
684
684
  - lib/samagotchi/testing/bundle.rb
685
+ - lib/samagotchi/testing/env_not_shown.rb
685
686
  - lib/samagotchi/testing/fake_executable.rb
686
687
  - lib/samagotchi/testing/fake_executable_launcher
687
688
  - lib/samagotchi/testing/fake_http.rb
@@ -865,6 +866,7 @@ files:
865
866
  - lib/samagotchi/web/public/method_api.js
866
867
  - lib/samagotchi/web/public/model_pick.js
867
868
  - lib/samagotchi/web/public/model_picker.js
869
+ - lib/samagotchi/web/public/more_sheet.js
868
870
  - lib/samagotchi/web/public/notice_links.js
869
871
  - lib/samagotchi/web/public/notifications.js
870
872
  - lib/samagotchi/web/public/notify.js