samagotchi 0.61.0 → 0.63.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -1
@@ -22,14 +22,17 @@
22
22
  // removed at the end: the answer bubble takes its place.
23
23
  import { html, nothing, render } from "./vendor/lit-html.js";
24
24
  import { setAnswerRefs } from "./answer_refs.js";
25
+ import { bindRowRefs } from "./refs.js";
25
26
  import { leavesBlock } from "./card.js";
26
- import { copySourceAttr } from "./copy.js";
27
- import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
28
- import { thumbsHtml } from "./images.js";
27
+ import { failedTurnText, normalize, sourceLabel } from "./format.js";
28
+ import { thumbsTemplate } from "./images.js";
29
29
  import { shouldFollowScroll } from "./scroll.js";
30
30
  import { hookNoticeLabel } from "./turn_events.js";
31
- import { commandBubbleHtml, emptyAnswerHtml, htmlElement, shellCommandMessage, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
32
- import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
31
+ import {
32
+ answerBubbleTemplate, cancelLineTemplate, commandBubbleTemplate, elementOf, emptyAnswerTemplate, noteBubbleTemplate, shellCommandMessage,
33
+ steerRowTemplate, toolRowInnerTemplate, toolRowTemplate, userBubbleTemplate,
34
+ } from "./turn_html.js";
35
+ import { formatDuration, turnGroups, turnTimingText } from "./timing.js";
33
36
  import { createSentenceFeed } from "./sentences.js";
34
37
  import { createTicker } from "./thinking_ticker.js";
35
38
  import * as model from "./turn_model.js";
@@ -109,41 +112,68 @@ function nodesOf(template) {
109
112
  return frag;
110
113
  }
111
114
 
112
- // +markup+ (any HTML string) as nodes.
113
- function rawNodes(markup) {
114
- const t = document.createElement("template");
115
- t.innerHTML = markup;
116
- return t.content;
115
+ // ---- A finished block's outline ----
116
+
117
+ // A finished block as its outline (turn_model.js outlineRuns of +gens+, its
118
+ // steps in order): open, each narrated step a one-line row as before, each
119
+ // run of steps that said nothing hidden behind one "N steps" fold that
120
+ // shows them. The live turn's end and a reload call it alike. The folds sit
121
+ // between the steps, not in them (`:scope > .gen` still counts steps), each
122
+ // before its run's first step and before that step's lit marker comment, so
123
+ // a later render of the step leaves it alone. False when there is nothing
124
+ // to outline (no step said anything): the block stays as it is.
125
+ export function outlineBlock(blockEl, gens) {
126
+ const runs = model.outlineRuns(gens);
127
+ const steps = blockEl ? [...blockEl.querySelectorAll(":scope > .gen")] : [];
128
+ if (!runs.length || steps.length !== gens.length) return false;
129
+ blockEl.classList.add("outline");
130
+ // A folded step → its fold's opener: a ref's "show in turn" (refs.js
131
+ // showInTurn dispatches ref:reveal on the row before it scrolls) unfolds
132
+ // the run its row is in, or the row stays hidden.
133
+ const unfold = new Map();
134
+ for (const { fold } of runs) {
135
+ if (!fold) continue;
136
+ const folded = fold.map((i) => steps[i]);
137
+ folded.forEach((el) => el.classList.add("folded"));
138
+ const marker = document.createElement("div");
139
+ marker.className = "outline-fold";
140
+ marker.setAttribute("role", "button");
141
+ marker.tabIndex = 0;
142
+ marker.setAttribute("aria-expanded", "false");
143
+ marker.textContent = `${fold.length} step${fold.length === 1 ? "" : "s"}`;
144
+ const toggle = () => {
145
+ const open = marker.classList.toggle("open");
146
+ marker.setAttribute("aria-expanded", String(open));
147
+ folded.forEach((el) => el.classList.toggle("unfolded", open));
148
+ };
149
+ folded.forEach((el) => unfold.set(el, () => { if (!marker.classList.contains("open")) toggle(); }));
150
+ marker.addEventListener("click", toggle);
151
+ marker.addEventListener("keydown", (e) => {
152
+ if (e.key !== "Enter" && e.key !== " ") return;
153
+ e.preventDefault();
154
+ toggle();
155
+ });
156
+ const first = folded[0];
157
+ const anchor = first.previousSibling?.nodeType === 8 ? first.previousSibling : first;
158
+ anchor.before(marker);
159
+ }
160
+ blockEl.addEventListener("ref:reveal", (e) => {
161
+ const step = e.target.closest?.(".gen.folded");
162
+ if (step?.parentElement === blockEl) unfold.get(step)?.();
163
+ });
164
+ blockEl.open = true;
165
+ return true;
117
166
  }
118
167
 
119
- // ---- The live view ----
120
-
121
- // The parts of a drawn tool row a user may have changed (the command's
122
- // raw toggle, an opened diff) or that never change once there (the
123
- // thumbs): a redraw keeps them as they are.
124
- const KEPT_PARTS = ["activity-command", "activity-diff", "activity-read", "thumbs"];
125
-
126
- const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains(cls));
127
-
128
- // A live tool row drawn again from its activity row (status, title,
129
- // output): the reloaded row's markup (toolRowInnerHtml), the kept parts
130
- // and the stop-task button carried over. +html+ is that markup.
131
- function redrawRow(el, markup) {
132
- const fresh = htmlElement(`<div>${markup}</div>`);
133
- for (const cls of KEPT_PARTS) {
134
- const old = childWith(el, cls);
135
- const drawn = childWith(fresh, cls);
136
- if (old && drawn) drawn.replaceWith(old);
137
- }
138
- const stop = childWith(el, "task-stop");
139
- el.replaceChildren(...fresh.childNodes);
140
- if (stop) rowAnchor(el).after(stop);
168
+ // Reloaded tool rows (turn_html.js toolRowTemplate) as nodes, each row's
169
+ // plugin refs bound to its title (refs.js bindRowRefs).
170
+ function rowNodes(rows, thumbs) {
171
+ const nodes = nodesOf(rows.map((r) => toolRowTemplate(r, { thumbs })));
172
+ [...nodes.children].forEach((el, i) => bindRowRefs(el, rows[i]));
173
+ return nodes;
141
174
  }
142
175
 
143
- // Where a row's stop-task button goes: after its title (else its tool).
144
- function rowAnchor(el) {
145
- return childWith(el, "activity-params") || childWith(el, "activity-tool");
146
- }
176
+ // ---- The live view ----
147
177
 
148
178
  // The container seams (the stage view composes this view, stage_view.js):
149
179
  // blockHost(el) puts the turn's block in the page (default: the history);
@@ -423,10 +453,11 @@ export function createTurnView({
423
453
  onChange();
424
454
  }
425
455
 
426
- // A tool row is the reloaded row's markup (turn_html.js toolRowHtml),
427
- // drawn again when an event changed it.
428
- const rowHtml = new WeakMap();
429
- const rowThumbs = (images) => thumbsHtml(sessionId(), images);
456
+ // A live tool row is its own element, the reloaded row's template
457
+ // (turn_html.js toolRowInnerTemplate) rendered into it on each event.
458
+ const rowThumbs = (images) => thumbsTemplate(sessionId(), images);
459
+ // A row element → its stop-task button (syncTaskStop).
460
+ const rowStops = new WeakMap();
430
461
 
431
462
  // A row, notice or card into the step's activity; a step that wrote no
432
463
  // narration before it drops the "…".
@@ -442,39 +473,48 @@ export function createTurnView({
442
473
  apply(r);
443
474
  const entry = genEls.get(r.gen);
444
475
  let el = entry.rows.get(r.row.key);
445
- const inner = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
446
476
  if (!el) {
447
- el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
448
- rowHtml.set(el, inner);
477
+ el = document.createElement("div");
478
+ el.className = "activity-row";
479
+ el.dataset.key = r.row.key;
449
480
  entry.rows.set(r.row.key, el);
481
+ drawRow(el, r.row);
450
482
  addItem(entry, el);
451
483
  } else {
452
484
  if (!r.gen.text) entry.placeholder = false;
453
- if (rowHtml.get(el) !== inner) redrawRow(el, inner);
454
- rowHtml.set(el, inner);
485
+ drawRow(el, r.row);
455
486
  // A late result in a closed step: its label and failed mark.
456
487
  draw(entry);
457
488
  }
458
- syncTaskStop(el, r.row);
459
489
  updateBlockSummary();
460
490
  onChange();
461
491
  }
462
492
 
463
- // The stop-task button after a running task_wait's params; gone once the
464
- // row completes.
493
+ // The row rendered again (lit writes only what changed: a toggled raw
494
+ // command, an opened diff or read and the thumbs stay), its stop-task
495
+ // button in its slot, its plugin refs bound to its title (refs.js).
496
+ function drawRow(el, row) {
497
+ render(toolRowInnerTemplate(row, { thumbs: rowThumbs, stop: syncTaskStop(el, row) }), el);
498
+ bindRowRefs(el, row);
499
+ }
500
+
501
+ // The stop-task button of a running task_wait's row (after its title),
502
+ // nothing once the row completes.
465
503
  function syncTaskStop(el, row) {
466
504
  const taskId = taskStop?.target(row);
467
- const btn = el.querySelector(":scope > .task-stop");
505
+ let btn = rowStops.get(el);
468
506
  if (!taskId) {
469
- btn?.remove();
470
- return;
507
+ rowStops.delete(el);
508
+ return nothing;
471
509
  }
472
510
  if (btn) {
473
511
  btn.disabled = taskStop.busy(taskId);
474
- return;
512
+ return btn;
475
513
  }
476
514
  const command = () => taskCommand(turn?.gens, taskId);
477
- rowAnchor(el).after(taskStopButton(taskStop, taskId, { command }));
515
+ btn = taskStopButton(taskStop, taskId, { command });
516
+ rowStops.set(el, btn);
517
+ return btn;
478
518
  }
479
519
 
480
520
  // A hook's line (hook_notice) as a row of the current step, above the row
@@ -494,7 +534,7 @@ export function createTurnView({
494
534
  // that ends first puts it in its last step). False with no running turn.
495
535
  function steer(data) {
496
536
  if (!turn || turn.ended) return false;
497
- pendingSteers.push(htmlElement(steerRowHtml(data)));
537
+ pendingSteers.push(elementOf(steerRowTemplate(data)));
498
538
  return true;
499
539
  }
500
540
 
@@ -603,7 +643,11 @@ export function createTurnView({
603
643
  if (blockEl) {
604
644
  updateBlockSummary();
605
645
  blockEl.classList.add("done");
606
- if (!blockToggled) blockEl.open = false;
646
+ // The outline (turn_model.js outlineRuns): open on its narrated steps'
647
+ // rows, as a reload draws it; a block with nothing said closes.
648
+ if (newestFirst) chronological();
649
+ const outlined = outlineBlock(blockEl, model.blockGens(turn));
650
+ if (!blockToggled) blockEl.open = outlined;
607
651
  for (const el of blockEl.querySelectorAll(".plugin-card")) {
608
652
  if (leavesBlock({ warn: el.classList.contains("warn"), kind })) kept.push(el);
609
653
  }
@@ -615,6 +659,20 @@ export function createTurnView({
615
659
  return kept;
616
660
  }
617
661
 
662
+ // The steps back in chronological order after newestFirst (a no-op
663
+ // otherwise); a fold (outlineBlock) stays before its run's first step.
664
+ function chronological() {
665
+ if (!blockEl || !turn) return;
666
+ for (const gen of turn.gens) {
667
+ const entry = genEls.get(gen);
668
+ if (entry?.el.parentNode !== blockEl) continue;
669
+ const nodes = stepNodes(entry);
670
+ const fold = nodes[0].previousSibling;
671
+ if (fold?.classList?.contains("outline-fold")) nodes.unshift(fold);
672
+ blockEl.append(...nodes);
673
+ }
674
+ }
675
+
618
676
  function reset() {
619
677
  hold.finish();
620
678
  turn = null;
@@ -712,17 +770,14 @@ export function createTurnView({
712
770
  // block, a step's elements by index, and the steps back in
713
771
  // chronological order after newestFirst.
714
772
  current() { return { turn, blockEl }; },
773
+ // The running turn's asking reply (turn_model.js askingText): what a
774
+ // question or approval card keeps once answered.
775
+ askingText: () => model.askingText(turn),
715
776
  step(i) {
716
777
  const entry = turn && genEls.get(turn.gens[i]);
717
778
  return entry ? { el: entry.el, summary: entry.el.querySelector(":scope > summary"), thinkingWrap: thinkingWrap(entry) } : null;
718
779
  },
719
- chronological() {
720
- if (!blockEl || !turn) return;
721
- for (const gen of turn.gens) {
722
- const entry = genEls.get(gen);
723
- if (entry?.el.parentNode === blockEl) blockEl.append(...stepNodes(entry));
724
- }
725
- },
780
+ chronological,
726
781
  };
727
782
  }
728
783
 
@@ -737,29 +792,23 @@ function defaultPlaceAnswer(appendToHistory) {
737
792
 
738
793
  // ---- A reload ----
739
794
 
795
+ // The text of a reloaded block's step +iteration+ (1-based, as the snapshot's
796
+ // cards count), "" when it has none: a reloaded question card's asking
797
+ // reply.
798
+ export function stepText(block, iteration) {
799
+ const step = block?.querySelectorAll(":scope > .gen")[Number(iteration) - 1];
800
+ return step?.querySelector(":scope > .gen-text")?.textContent || "";
801
+ }
802
+
740
803
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
741
804
  // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
742
805
  // record kept of it (SessionMetrics' failure: summary, message,
743
- // error_class, kept_steps); "" for any other record, or a failed one saved
744
- // before records kept that.
745
- function failedLineHtml(record) {
806
+ // error_class, kept_steps); nothing for any other record, or a failed one
807
+ // saved before records kept that.
808
+ function failedLineTemplate(record) {
746
809
  const failure = record?.status === "failed" ? record.failure : null;
747
- if (!failure || typeof failure !== "object") return "";
748
- return `<div class="bubble cancel">${escapeHtml(failedTurnText(failure))}</div>`;
749
- }
750
-
751
- // Parts (HTML strings and templates, in order) as one fragment.
752
- function fragmentOf(parts) {
753
- const out = document.createDocumentFragment();
754
- let markup = "";
755
- for (const part of parts) {
756
- if (typeof part === "string") { markup += part; continue; }
757
- if (markup) out.append(rawNodes(markup));
758
- markup = "";
759
- out.append(nodesOf(part));
760
- }
761
- if (markup) out.append(rawNodes(markup));
762
- return out;
810
+ if (!failure || typeof failure !== "object") return nothing;
811
+ return html`<div class="bubble cancel">${failedTurnText(failure)}</div>`;
763
812
  }
764
813
 
765
814
  // A reloaded history in the turn view (renderHistory's object path), as
@@ -768,51 +817,51 @@ function fragmentOf(parts) {
768
817
  // the thinking, params and output, from the saved messages; status and
769
818
  // duration from the timing's tool records), then the answer and the turn's
770
819
  // timing line under it.
771
- // +thumbs(images)+ draws a prompt's or a tool row's image thumbs.
820
+ // +thumbs(images)+ draws a prompt's or a tool row's image thumbs (a
821
+ // template, images.js thumbsTemplate).
772
822
  export function turnHistoryNodes(items, timing, { thumbs }) {
773
- const timingHtml = (group) => {
823
+ const timingTemplate = (group) => {
774
824
  const record = group.record;
775
825
  // Numbered by its place in the turn records, as live (a failed turn
776
826
  // whose prompt went back still counts).
777
827
  const at = (timing?.turnRecords || []).indexOf(record);
778
828
  return record && formatDuration(record.duration_ms)
779
- ? `<div class="turn-timing">${escapeHtml(turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
780
- : "";
829
+ ? html`<div class="turn-timing">${turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" })}</div>`
830
+ : nothing;
781
831
  };
782
- // The timing line after the bubble, not in it: where the live view
783
- // leaves it at the turn's end. +at+: the message's index, for its refs
832
+ // An answer's bubble; +at+, the message's index, is for its refs
784
833
  // (data-answer-at, gone before the nodes are returned).
785
- const answerHtml = (at, timing) => {
786
- const m = items[at];
787
- const { body, renderedMarkdown } = messageBodyHtml(m);
788
- return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}" data-answer-at="${at}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
789
- };
834
+ const answerBubble = (at, cls = "") => answerBubbleTemplate(items[at], { cls, at });
835
+ // The timing line after the bubble, not in it: where the live view
836
+ // leaves it at the turn's end.
837
+ const answerTemplate = (at, timing) => html`${answerBubble(at)}${timing}`;
790
838
  // A user's line, labelled when chi brought it in (a delegate report). A
791
839
  // `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
792
840
  // as its command bubble, as live does — not as a user bubble.
793
- const userHtml = (m, opts = {}) => {
841
+ const userTemplate = (m, opts = {}) => {
794
842
  const shell = shellCommandMessage(m?.content);
795
- if (shell) return commandBubbleHtml(shell);
796
- return userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
843
+ if (shell) return commandBubbleTemplate(shell);
844
+ return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
797
845
  };
798
846
  // The first text after a delegate report merged mid-turn, unless it is
799
847
  // the turn's answer (shown anyway).
800
- const reportReplyHtml = (i, group) => {
801
- if (items[i]?.source !== "delegate_report") return "";
848
+ const reportReplyTemplate = (i, group) => {
849
+ if (items[i]?.source !== "delegate_report") return nothing;
802
850
  const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
803
- if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
804
- const { body, renderedMarkdown } = messageBodyHtml(items[j]);
805
- return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}" data-answer-at="${j}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
851
+ if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return nothing;
852
+ return answerBubble(j, " report-reply");
806
853
  };
807
- const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
854
+ const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
808
855
  const parts = [];
856
+ // Each block's steps, in order: its outline once the nodes exist.
857
+ const outlines = [];
809
858
  for (const group of turnGroups(items, timing)) {
810
- if (group.kind === "note") { parts.push(`<div class="bubble note">${noteHtml(items[group.i])}</div>`); continue; }
811
- if (group.kind === "steer") { parts.push(steerHtml(group.i, true)); continue; }
812
- if (group.kind === "answer") { parts.push(answerHtml(group.answer, "")); continue; }
859
+ if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
860
+ if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
861
+ if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
813
862
  const prompt = items[group.user];
814
863
  // A context wake turn starts with its note.
815
- parts.push(prompt.role === "note" ? `<div class="bubble note">${noteHtml(prompt)}</div>` : userHtml(prompt));
864
+ parts.push(prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt));
816
865
  const [only] = group.steps;
817
866
  const ended = group.answer !== null || group.emptyAnswer != null;
818
867
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -826,36 +875,38 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
826
875
  const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
827
876
  const steps = gens.map((gen, k) => stepTemplate(gen, {
828
877
  // The steers the step answered first, where the live rows went.
829
- steers: (group.steps[k].steers || []).map((i) => rawNodes(steerHtml(i, false))),
830
- items: gen.tools.length ? [rawNodes(gen.tools.map((r) => toolRowHtml(r, { thumbs })).join(""))] : [],
878
+ steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
879
+ items: gen.tools.length ? [rowNodes(gen.tools, thumbs)] : [],
831
880
  }));
832
881
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
882
+ outlines.push(gens);
833
883
  }
834
884
  // Steers with no step to sit in, or in a turn that shows no block: on
835
885
  // their own after the prompt.
836
886
  const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
837
- parts.push(loose.map((i) => steerHtml(i, true)).join(""));
887
+ parts.push(loose.map((i) => steerTemplate(i, true)));
838
888
  // The user's lines merged into the turn: the live "steered" bubbles,
839
889
  // after its steps and before its answer. A delegate report's is
840
890
  // followed by the text that answered it when that isn't the answer: a
841
891
  // small model tells its user about a report before its next tool call,
842
892
  // and that step's text would stay folded in the block.
843
893
  parts.push((group.merged || []).map((i) =>
844
- userHtml(items[i], { state: "steered", step: items[i].step }) +
845
- reportReplyHtml(i, group)).join(""));
894
+ html`${userTemplate(items[i], { state: "steered", step: items[i].step })}${reportReplyTemplate(i, group)}`));
846
895
  // A turn that ended with no answer: chi's muted notice where the answer
847
896
  // would be, before the timing, as live.
848
- if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
897
+ if (group.emptyAnswer != null) parts.push(html`${emptyAnswerTemplate(items[group.emptyAnswer])}${timingTemplate(group)}`);
849
898
  // A turn with no answer (canceled, or running: no record yet) shows its
850
899
  // timing after its prompt and steps, where the live view leaves it.
851
- else if (group.answer === null) parts.push(timingHtml(group));
852
- else parts.push(answerHtml(group.answer, timingHtml(group)));
900
+ else if (group.answer === null) parts.push(timingTemplate(group));
901
+ else parts.push(answerTemplate(group.answer, timingTemplate(group)));
853
902
  // A canceled turn ends with its cancel line, a failed one whose steps
854
903
  // stayed with its failure line, as the live view left them.
855
- parts.push(cancelLineHtml(group.record, escapeHtml));
856
- parts.push(failedLineHtml(group.record));
904
+ parts.push(cancelLineTemplate(group.record));
905
+ parts.push(failedLineTemplate(group.record));
857
906
  }
858
- const out = fragmentOf(parts);
907
+ const out = nodesOf(parts);
908
+ // A finished turn's block as the live end leaves it: its outline, open.
909
+ [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
859
910
  // Each answer bubble knows its message's refs (answer_refs.js; the
860
911
  // page pairs them after the render), the index stays out of the DOM.
861
912
  for (const bubble of out.querySelectorAll("[data-answer-at]")) {
@@ -0,0 +1,8 @@
1
+ // lit-html 3.3.3 (npm tarball lit-html-3.3.3.tgz, directive.js: the minified production
2
+ // build, unmodified but for this line and the dropped sourceMappingURL). BSD-3-Clause.
3
+ /**
4
+ * @license
5
+ * Copyright 2017 Google LLC
6
+ * SPDX-License-Identifier: BSD-3-Clause
7
+ */
8
+ const t={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},e=t=>(...e)=>({_$litDirective$:t,values:e});class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}export{i as Directive,t as PartType,e as directive};
@@ -0,0 +1,8 @@
1
+ // lit-html 3.3.3 (npm tarball lit-html-3.3.3.tgz, directives/unsafe-html.js: the minified
2
+ // production build, unmodified but for this line and the dropped sourceMappingURL). BSD-3-Clause.
3
+ import{nothing as t,noChange as i}from"../lit-html.js";import{directive as r,Directive as s,PartType as n}from"../directive.js";
4
+ /**
5
+ * @license
6
+ * Copyright 2017 Google LLC
7
+ * SPDX-License-Identifier: BSD-3-Clause
8
+ */class e extends s{constructor(i){if(super(i),this.it=t,i.type!==n.CHILD)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(r){if(r===t||null==r)return this._t=void 0,this.it=r;if(r===i)return r;if("string"!=typeof r)throw Error(this.constructor.directiveName+"() called with a non-string value");if(r===this.it)return this._t;this.it=r;const s=[r];return s.raw=s,this._t={_$litType$:this.constructor.resultType,strings:s,values:[]}}}e.directiveName="unsafeHTML",e.resultType=1;const o=r(e);export{e as UnsafeHTMLDirective,o as unsafeHTML};
@@ -468,10 +468,10 @@ module Samagotchi
468
468
  # would keep the hub's "needs you" badge on for good. Dropped and saved
469
469
  # before the Engine and the Bridge exist (no question lock, no events).
470
470
  def drop_dead_question
471
- return unless @session.pending_question
471
+ id = @session.drop_dead_question
472
+ return unless id
472
473
 
473
- Log.info(:worker, "dead_question_dropped", id: @session.pending_question[:id])
474
- @session.pending_question = nil
474
+ Log.info(:worker, "dead_question_dropped", id: id)
475
475
  save_or_log(:dead_question) { @session.save(state_dir: @state_dir) }
476
476
  end
477
477
 
@@ -1077,8 +1077,9 @@ module Samagotchi
1077
1077
  # A wake turn starts with its first line, whoever sent it.
1078
1078
  lines[0] = lines[0].with(mark: mark) if mark && lines.any?
1079
1079
  # The turn so far, with what this boundary merges (its input files
1080
- # are gone now), on disk at most every TurnCheckpoint::INTERVAL.
1081
- @checkpoint.boundary(@engine.live_conversation, merging: lines, turn_id: @turn_id)
1080
+ # are gone now), on disk at most every TurnCheckpoint::INTERVAL;
1081
+ # its metrics so far with it, so a turn a KILL ends keeps them.
1082
+ @engine.checkpoint_metrics if @checkpoint.boundary(@engine.live_conversation, merging: lines, turn_id: @turn_id)
1082
1083
  lines
1083
1084
  end
1084
1085
  end
@@ -1102,22 +1103,36 @@ module Samagotchi
1102
1103
  end
1103
1104
 
1104
1105
  # Check again with the event log held, which the Bridge holds while it
1105
- # queues a POST /turn, then close the Bridge so no client can queue one
1106
- # after the check, and stop the idle jobs (reminder callback, recap).
1106
+ # queues a POST /turn, then close the Bridge's intake so no client can
1107
+ # queue one after the check, and stop the idle jobs (reminder callback,
1108
+ # recap). The Bridge stops after the log is let go (#stop_bridge_after).
1107
1109
  # A client connecting from here on finds no worker: `chi --attach` fails
1108
1110
  # and the web stream answers 503 (a small window, left as is).
1109
1111
  # @return [Boolean] false when something came in since #due?
1110
1112
  def leave_idle
1111
- @engine.synchronize_events do
1112
- next false unless @idle_exit.due?
1113
-
1114
- @bridge&.stop
1115
- @engine.stop_idle
1116
- log_idle_exit
1117
- true
1113
+ stop_bridge_after do
1114
+ @engine.synchronize_events do
1115
+ next false unless @idle_exit.due?
1116
+
1117
+ @bridge&.close_intake
1118
+ @engine.stop_idle
1119
+ log_idle_exit
1120
+ true
1121
+ end
1118
1122
  end
1119
1123
  end
1120
1124
 
1125
+ # The Bridge stops once the block (which closed its intake with the
1126
+ # event log held) answers true, outside the log: Bridge#stop waits for
1127
+ # the requests it is answering, and one waiting on the log would hold
1128
+ # it for the whole grace before being killed.
1129
+ # @return [Boolean] what the block answered
1130
+ def stop_bridge_after
1131
+ leaving = yield
1132
+ @bridge&.stop if leaving
1133
+ leaving
1134
+ end
1135
+
1121
1136
  # Bridge POST /exit, on a Bridge thread with the event log held.
1122
1137
  # +delete+: the session is deleted after (/exit --delete), so no recap.
1123
1138
  # +restart+: a new worker takes over (on the newest chi installed), so
@@ -1139,29 +1154,31 @@ module Samagotchi
1139
1154
  end
1140
1155
 
1141
1156
  # #leave_idle for an exit a client asked for: check again with the event
1142
- # log held (a prompt may have come in since), then close the Bridge and
1143
- # stop the idle jobs. Other clients' streams no longer hold: the asker's
1157
+ # log held (a prompt may have come in since), then close the Bridge's
1158
+ # intake and stop the idle jobs (the Bridge stops after the log). Other clients' streams no longer hold: the asker's
1144
1159
  # may not have closed yet, and a UI joining now finds the worker gone
1145
1160
  # (:stream_closed), the window #leave_idle has too.
1146
1161
  # @return [Boolean] false when something came in since the request
1147
1162
  def leave_on_request
1148
- @engine.synchronize_events do
1149
- hold = if @exit_restart
1150
- @idle_exit.hold_for_restart
1151
- else
1152
- @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
1153
- end
1154
- if hold
1155
- @exit_requested = nil
1156
- @exit_restart = false
1157
- Log.info(:worker, "exit_held", reason: hold)
1158
- next false
1159
- end
1163
+ stop_bridge_after do
1164
+ @engine.synchronize_events do
1165
+ hold = if @exit_restart
1166
+ @idle_exit.hold_for_restart
1167
+ else
1168
+ @idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
1169
+ end
1170
+ if hold
1171
+ @exit_requested = nil
1172
+ @exit_restart = false
1173
+ Log.info(:worker, "exit_held", reason: hold)
1174
+ next false
1175
+ end
1160
1176
 
1161
- @bridge&.stop
1162
- @engine.stop_idle
1163
- Log.info(:worker, @exit_restart ? "restart_requested" : "exit_requested", by: @exit_requested_by || "a client")
1164
- true
1177
+ @bridge&.close_intake
1178
+ @engine.stop_idle
1179
+ Log.info(:worker, @exit_restart ? "restart_requested" : "exit_requested", by: @exit_requested_by || "a client")
1180
+ true
1181
+ end
1165
1182
  end
1166
1183
  end
1167
1184
 
@@ -106,9 +106,10 @@ module Samagotchi
106
106
  # run): auto-attached context alone doesn't make a session worth
107
107
  # keeping. Saved, then the subscriptions written (a crash between the
108
108
  # two re-delivers; the note ids dedupe).
109
- # +wake+: the session is idle with nothing queued, so an update whose
110
- # source asked to wake may start a turn (#context_wake_for): its note
111
- # goes in as the turn's first message.
109
+ # +wake+: the session is idle with nothing queued, so a delivery with a
110
+ # wake note (an update whose source asked to wake, or a session push
111
+ # source's first text that asked) may start a turn (#context_wake_for):
112
+ # its wake note goes in as the turn's first message.
112
113
  # @return [ContextWake, nil] the wake turn to run now
113
114
  # @raise [SystemCallError, IOError] the caller logs it (context_failed)
114
115
  def absorb_context(before_turn: false, wake: false)
@@ -134,7 +135,7 @@ module Samagotchi
134
135
  waking && ContextWake.new(delivery: waking, turn_id: turn_id)
135
136
  end
136
137
 
137
- # A failed wake turn kept nothing but its note: the update goes back to
138
+ # A failed wake turn kept nothing but its note: the note goes back to
138
139
  # a plain note (no turn start, the background wording), so a reload
139
140
  # draws no empty turn for it.
140
141
  def unmark_wake(context_wake)
@@ -201,9 +202,11 @@ module Samagotchi
201
202
 
202
203
  private
203
204
 
204
- # +unless_stopped+: not when the session was stopped meanwhile: the stop
205
- # (chi stop, from another process) saved its status, and this save
206
- # would write the worker's over it.
205
+ # +unless_stopped+: skipped when the session was stopped meanwhile (chi
206
+ # sessions stop, from another process: its marker file,
207
+ # Session.mark_stopped, which a load reads over the saved status). The
208
+ # save couldn't hide the stop; it is left out so the file stays as the
209
+ # stop found it, the worker's idle status unwritten.
207
210
  def save(at, unless_stopped: false)
208
211
  self.class.save_or_log(at) do
209
212
  @session.save(state_dir: @state_dir) unless unless_stopped && @stopped.call
@@ -222,13 +225,13 @@ module Samagotchi
222
225
  true
223
226
  end
224
227
 
225
- # The update in +batch+ that may start a wake turn now: its source
226
- # asked (wake: true), context.wake is on, the wake budget is open
227
- # (WorkerWakes#context_open?: no continue offer, not paused, under
228
- # session.max_wakes, past the start grace) and the source hasn't woken
229
- # the session in the last CONTEXT_WAKE_WINDOW. The others stay plain
230
- # notes. Within the start grace a change is a note: it came while the
231
- # session was away.
228
+ # The delivery in +batch+ that may start a wake turn now: it has a wake
229
+ # note (ContextAbsorber decides: its source asked, wake: true),
230
+ # context.wake is on, the wake budget is open (WorkerWakes#context_open?:
231
+ # no continue offer, not paused, under session.max_wakes, past the start
232
+ # grace) and the source hasn't woken the session in the last
233
+ # CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
234
+ # grace a change is a note: it came while the session was away.
232
235
  def context_wake_for(batch, now: Time.now)
233
236
  candidates = batch.deliveries.select(&:wake_note)
234
237
  return nil if candidates.empty? || !context_wakes_on?